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

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.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.