Part of the Choosing a Renderer: Folium vs MapLibre GL vs PyDeck guide.
Operative rule: the migration is a move from per-feature DOM/SVG nodes (Leaflet, under Folium) to GPU-drawn vector tiles (MapLibre GL) — the win comes from tiling the data, not from a like-for-like GeoJSON swap.
How It Works
Folium is a Python wrapper around Leaflet, and Leaflet renders every marker and polygon as an individual DOM or SVG node. That model is superb for a few hundred features and pleasant to author, but the cost is linear in node count: the browser must lay out, paint, and hit-test each element. Somewhere between ~10,000 and ~50,000 features the main thread saturates, pan and zoom drop frames, and tooltips lag. No amount of Python tuning fixes this because the bottleneck is the client rendering strategy, not the build. Choosing between these engines is the whole subject of the renderer selection: Folium vs MapLibre GL vs PyDeck guide; this page is the concrete port once you have decided Folium has run out of headroom.
MapLibre GL JS takes the opposite approach: it uploads geometry to the GPU and draws it in WebGL, so a whole tile of features costs roughly one draw call rather than thousands of DOM operations. To feed it efficiently you stop shipping one giant GeoJSON and instead serve a pre-cut vector-tile pyramid — the exact artifact produced in generating vector tiles from PostGIS with Tippecanoe. Styling that was a Folium style_function running per feature in Python becomes a data-driven paint expression evaluated on the GPU. This is the same client-side trade-off framed in tile vs vector rendering strategies: move the per-feature work off the DOM and onto the tile pipeline plus the GPU. Crucially, your Python build step survives — it just gains a tiling stage.
Production-Ready Implementation
Start from the typical Folium overlay you are replacing — a folium.GeoJson with a per-feature style_function:
# BEFORE — Folium: one SVG node per feature, style computed per feature.
import folium
m = folium.Map(location=[40.73, -73.99], zoom_start=11, tiles="CartoDB positron")
def style_fn(feature: dict) -> dict:
zoning = feature["properties"]["zoning"]
return {
"fillColor": {"residential": "#4c78a8"}.get(zoning, "#bab0ac"),
"color": "#333",
"weight": 0.4,
"fillOpacity": 0.6,
}
folium.GeoJson("build/parcels.geojson", style_function=style_fn).add_to(m)
m.save("folium_map.html") # unusable interactivity past tens of thousands of parcels
The Python data step stays; you add a tiling stage instead of loading raw GeoJSON in the browser:
# AFTER — keep the build, add a tiling stage. (Run in your pipeline / CI.)
import subprocess
# The GeoDataFrame → GeoJSON export you already had is unchanged.
# New: cut a vector-tile pyramid so the browser never sees raw features.
subprocess.run(
[
"tippecanoe",
"-o", "dist/parcels.pmtiles",
"-z14", "-Z6",
"--drop-densest-as-needed", # keep tiles under the MVT size limit
"--coalesce", # merge adjacent same-attribute polygons
"-l", "parcels", # stable source-layer name for the style
"--force",
"build/parcels.geojson", # must be EPSG:4326 lon/lat
],
check=True,
)
The Folium map is replaced by a MapLibre GL JS v4 page. The style_function becomes a match paint expression evaluated on the GPU:
import maplibregl from "maplibre-gl";
import { Protocol } from "pmtiles";
// Serve the tiled archive via the pmtiles:// protocol (no tile server needed).
maplibregl.addProtocol("pmtiles", new Protocol().tile);
const map = new maplibregl.Map({
container: "map",
style: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
center: [-73.99, 40.73], // [lng, lat]
zoom: 11,
});
map.on("load", () => {
map.addSource("parcels", {
type: "vector",
url: "pmtiles:///dist/parcels.pmtiles",
});
// The former Folium style_function, now a data-driven paint expression.
map.addLayer({
id: "parcels-fill",
type: "fill",
source: "parcels",
"source-layer": "parcels", // matches tippecanoe -l
paint: {
"fill-color": [
"match",
["get", "zoning"],
"residential", "#4c78a8",
"#bab0ac",
],
"fill-opacity": 0.6,
},
});
// Per-feature interactivity (Folium tooltip → MapLibre event + queryRenderedFeatures)
map.on("click", "parcels-fill", (e) => {
const p = e.features?.[0]?.properties ?? {};
new maplibregl.Popup()
.setLngLat(e.lngLat)
.setHTML(`Parcel ${p.parcel_id ?? ""} — ${p.zoning ?? ""}`)
.addTo(map);
});
});
Alternative Variants
Keep a raw GeoJSON source below the tiling threshold
If the layer is genuinely small (under ~10k features) you can skip tiling and still gain the GPU renderer by using a geojson source directly — useful for a lightweight migration:
map.addSource("sites", { type: "geojson", data: "/dist/sites.geojson" });
map.addLayer({
id: "sites",
type: "circle",
source: "sites",
paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 6, 2, 14, 6],
"circle-color": "#f58518",
},
});
Migration mapping reference
| Folium / Leaflet concept | MapLibre GL equivalent | Notes |
|---|---|---|
folium.GeoJson(data) |
geojson or vector source + layer |
Tile above ~10k features |
style_function (per feature, Python) |
paint expression (match, interpolate) |
Evaluated on the GPU |
folium.GeoJsonTooltip |
map.on("mousemove", ...) / popup |
Uses queryRenderedFeatures |
folium.LayerControl |
setLayoutProperty(..., "visibility") toggles |
Custom control UI |
m.save("map.html") |
static HTML host + .pmtiles archive |
Keep the Python build |
Verification Steps
- Feature-count baseline — record the Folium map’s feature count and note the pan/zoom frame rate in DevTools → Performance so you can prove the improvement.
- Tile presence — after tiling, confirm
dist/parcels.pmtilesexists andtippecanoe-decodereports theparcelslayer at the expected zoom range. - Source-layer match — confirm the style’s
source-layerstring equals thetippecanoe -lvalue; a mismatch renders nothing. - Frame-rate check — pan and zoom the MapLibre map at full data volume; the main thread should stay responsive where Folium dropped frames.
- Interactivity parity — click and hover to confirm popups return the same properties the Folium tooltip showed.
Common Errors & Fixes
The MapLibre map is blank after migration
Almost always a source-layer mismatch or the PMTiles protocol was not registered. Fix: set the layer’s source-layer to the exact tippecanoe -l name, and call maplibregl.addProtocol("pmtiles", ...) before constructing the Map.
Colors do not vary per feature like the Folium style_function did
A static fill-color string was used instead of a data-driven expression. Fix: replace it with a match or interpolate expression reading a feature property, e.g. ["match", ["get", "zoning"], ...], so styling is evaluated per feature on the GPU.
Performance is still poor even on MapLibre
Raw GeoJSON with hundreds of thousands of features was loaded as a geojson source, so the client still parses everything up front. Fix: tile the data with tippecanoe and use a vector source; the point of the migration is to stop shipping all features at once.
Tooltips or clicks return undefined properties
The attribute was dropped during tiling because it was not selected, or the property name changed. Fix: include the needed columns in the ogr2ogr/GeoJSON export before tiling, and read the exact property key in e.features[0].properties.
Related
- Choosing a Renderer: Folium vs MapLibre GL vs PyDeck — parent guide on picking the engine before you port
- Tile vs Vector Rendering Strategies — why moving per-feature work to the tile pipeline is the core of the migration
- Generating Vector Tiles from PostGIS with Tippecanoe — the tiling stage this migration depends on
- Rendering a Million Points with PyDeck ScatterplotLayer — the other high-volume renderer option when points, not polygons, dominate