Migrating a Folium Map to MapLibre GL for Large Datasets

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.

Folium DOM path vs MapLibre GPU path Both paths start from the same Python GeoJSON build. The Folium path sends the whole GeoJSON to Leaflet, which creates one DOM node per feature and stalls past tens of thousands. The MapLibre path tiles the GeoJSON with tippecanoe and draws tiles on the GPU, scaling to millions. Python build GeoDataFrame → GeoJSON BEFORE — Folium / Leaflet whole GeoJSON to the browser 1 DOM node / feature stalls > ~50k AFTER — MapLibre GL tippecanoe tiles MVT pyramid GPU draw / tile scales to millions

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);
  });
});

Most of a migration is mechanical: each Folium construct has a MapLibre counterpart, and knowing the mapping up front stops you from rewriting the dashboard’s logic while you are only meant to be swapping its renderer.

Folium construct to MapLibre GL counterpart folium.Map becomes new maplibregl.Map with a style document. folium.GeoJson becomes addSource plus one addLayer per geometry type. A Python style_function becomes a data-driven expression in the layer paint object. folium.Popup becomes a maplibregl.Popup bound to a click handler. folium.LayerControl becomes your own control that calls setLayoutProperty. folium.TileLayer becomes a raster source in the style document. What each Folium call becomes on the MapLibre side Folium (Python, build time) MapLibre GL (JavaScript, runtime) folium.Map(location, zoom_start) new maplibregl.Map({ center: [lon, lat], zoom, style }) folium.GeoJson(gdf) addSource(id, …) + one addLayer per geometry type style_function=lambda f: {…} paint: { "fill-color": ["interpolate", …, ["get", "value"]] } folium.Popup(html) map.on("click", layerId, …) → new maplibregl.Popup() folium.LayerControl() your own control → setLayoutProperty(id, "visibility", …) The style_function row is the one that costs real time: per-feature Python callbacks become declarative expressions evaluated on the GPU.

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

What the migration actually buys

The payoff is not subtle, and it grows with the dataset. The figures below are the shape of the change on a mid-range laptop: an inline GeoJSON blob parsed on the main thread versus tiled geometry streamed only for the current viewport.

Payload and frame rate before and after migrating to MapLibre GL At ten thousand features Folium ships roughly eight megabytes of inline GeoJSON and holds about forty frames per second while MapLibre streams around four hundred kilobytes per viewport at sixty frames per second. At one hundred thousand features Folium reaches eighty megabytes and single-digit frame rates while MapLibre stays near one megabyte per viewport at sixty frames per second. At one million features Folium fails to load at all while MapLibre still streams about two megabytes per viewport at around fifty frames per second. Initial payload and interaction frame rate, same dataset, two renderers features Folium — inline GeoJSON MapLibre — tiled per viewport 10 000 ≈ 8 MB parsed on the main thread ≈ 40 fps while panning ≈ 400 KB for the visible tiles 60 fps 100 000 ≈ 80 MB — multi-second parse stall single-digit fps, dropped drags ≈ 1 MB for the visible tiles 60 fps 1 000 000 does not load — tab runs out of memory not a tuning problem, an architecture one ≈ 2 MB for the visible tiles ≈ 50 fps with labels on The constant is what matters: tiled payload tracks the viewport, inline payload tracks the dataset.

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.pmtiles exists and tippecanoe-decode reports the parcels layer at the expected zoom range.
  • Source-layer match — confirm the style’s source-layer string equals the tippecanoe -l value; 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.