Choosing a Renderer: Folium vs MapLibre GL vs PyDeck
Part of the Core Mapping Architecture & Rendering guide.
Picking the rendering engine is the highest-leverage architectural decision in an automated geo-dashboard, because it constrains every downstream choice: how many features you can show, whether users can hover and filter them, how you style them, and how you ship the result. Three engines dominate Python map generation, and they occupy genuinely different niches rather than competing head-to-head. folium wraps Leaflet for fast, DOM-based maps of small-to-medium datasets with a trivial static HTML export. maplibre-gl renders WebGL vector tiles for large, interactive, dynamically styled maps. pydeck binds deck.gl’s GPU layers for aggregating hundreds of thousands to millions of points. This guide gives frontend and full-stack developers, GIS analysts, and dashboard engineers a decision framework — assess, prototype, detect the wall, then commit — instead of a religious argument. The stack components in play are folium 0.16+, maplibre-gl JS v4, pydeck 0.9+, and geopandas 0.14+ feeding all three.
Prerequisites
Before you can apply the framework, get the tooling and inputs in place so each engine can be prototyped without yak-shaving:
If your source data is not yet in a web-ready projection, resolve that first through CRS & Projection Management; every engine below assumes clean EPSG:4326 or EPSG:3857 coordinates.
Step 1 — Assess Data Volume and Interactivity Requirements
Every renderer choice collapses to four measurable variables. Quantify them before writing a line of rendering code, because guessing here means rewriting the whole export path later.
- Feature count. How many points, lines, or polygons render at once? Ranges matter more than exact numbers: under ~5,000, tens of thousands, or millions.
- Per-feature interactivity. Do users hover, click, or filter individual features, or is the map a read-only picture? Individually interactive features are cheap in the DOM and expensive on the GPU; aggregated views are the reverse.
- Styling dynamism. Is styling fixed at build time, or does it change at runtime from a slider, a time step, or a data-driven expression? Runtime restyling of large data is where vector tiles and GPU layers pull decisively ahead.
- Export target. Static single-file HTML on a CDN, or a served application with a live tile endpoint?
Write these down as a short spec. A dataset of 2,000 interactive store locations exported as static HTML is a completely different problem from 3 million GPS pings aggregated into a hexbin heatmap, and the four variables make that obvious before you commit.
from dataclasses import dataclass
@dataclass(frozen=True)
class RenderProfile:
"""The four variables that decide the rendering engine."""
feature_count: int
per_feature_interactive: bool # hover/click on individual features?
runtime_restyling: bool # styling changes after load?
static_export: bool # single-file HTML, no server?
def recommend_engine(p: RenderProfile) -> str:
"""Map a profile to a first-choice engine. Deliberately opinionated."""
if p.feature_count > 200_000:
return "pydeck" # GPU aggregation territory
if p.feature_count <= 5_000 and p.static_export and not p.runtime_restyling:
return "folium" # fastest path to a working static map
return "maplibre-gl" # large-ish, interactive, or dynamically styled
The decision matrix below is the reference the function above encodes. Keep it next to the spec:
| Dimension | folium (Leaflet) | maplibre-gl (vector tiles) | pydeck / deck.gl (GPU) |
|---|---|---|---|
| Comfortable dataset size | up to ~5k features | tens of thousands via tiles | 100k–millions of points |
| Per-feature interactivity | excellent (DOM/SVG) | good (feature-state) | aggregate-first, picking possible |
| Runtime dynamic styling | limited | excellent (data-driven expressions) | good (GPU accessors) |
| GPU acceleration | none | yes (WebGL) | yes (WebGL, batched) |
| Export path | trivial static HTML | static HTML or served tiles | static HTML via to_html() |
| Learning curve | gentle | moderate (style spec) | moderate (layer/accessor model) |
Step 2 — Prototype in Folium
Always build the first pass in folium. Even when you suspect you will outgrow it, a Folium prototype answers questions no amount of planning can: does the data project correctly, do the popups say the right thing, is the basemap legible? Folium wraps Leaflet, so it renders each feature as a DOM node or SVG path and serialises the entire map — coordinates, styles, popups — into one self-contained HTML file. For anything under a few thousand features that is a feature, not a limitation.
import folium
import geopandas as gpd
gdf = gpd.read_file("stores.geojson") # small, EPSG:4326
gdf = gdf.to_crs("EPSG:4326") # Folium wants lat/lon degrees
m = folium.Map(location=[40.71, -74.01], zoom_start=11, tiles="CartoDB positron")
for _, row in gdf.iterrows():
folium.CircleMarker(
location=[row.geometry.y, row.geometry.x], # Folium is [lat, lon]
radius=6,
popup=folium.Popup(row["name"], max_width=200),
fill=True,
).add_to(m)
folium.LayerControl().add_to(m)
m.save("stores.html") # one static file, zero server required
The coordinate order trap here is real: Folium expects [latitude, longitude], the reverse of GeoJSON. That contract, and the underlying Leaflet projection, is covered in depth in Implementing EPSG:3857 vs EPSG:4326 in Folium. If your prototype renders cleanly and pans smoothly at production scale, you are done — do not add WebGL complexity you do not need.
Step 3 — Detect the Scaling Wall
Folium does not fail loudly; it degrades. Knowing the symptoms tells you exactly when to migrate rather than guessing. Three signals mark the wall:
- DOM saturation. Each
CircleMarkerorGeoJsonfeature becomes an element the browser must lay out, paint, and hit-test. Past roughly 5,000 features, pan and zoom stutter as the main thread churns through thousands of nodes. - Payload bloat. Because Folium inlines every coordinate as JSON, the exported
.htmlgrows linearly with feature count. A 50,000-feature map can exceed 20 MB, blowing your CDN budget and mobile load time. - No GPU offload. Leaflet’s canvas renderer helps, but the geometry and interaction logic still run on the CPU main thread. There is no path to millions of features.
A quick guardrail in the pipeline catches the wall automatically instead of relying on someone noticing jank:
FOLIUM_FEATURE_CEILING = 5_000
def folium_is_viable(feature_count: int) -> bool:
"""Fail the build toward a GPU/vector engine past the DOM ceiling."""
if feature_count > FOLIUM_FEATURE_CEILING:
print(
f"{feature_count} features exceeds the Folium DOM ceiling "
f"({FOLIUM_FEATURE_CEILING}); route to MapLibre GL or PyDeck."
)
return False
return True
When this trips, the direction of the migration depends on Step 1’s interactivity variable, decided in Steps 4 and 5. The mechanics of moving an existing Folium map across the wall are walked through in Migrating a Folium Map to MapLibre GL for Large Datasets.
Step 4 — Choose MapLibre GL for Vector-Tile Interactivity
When you are past the wall but still need users to hover, click, and filter individual features — and you need styling that responds to data at runtime — maplibre-gl is the answer. It renders WebGL vector tiles, so tens of thousands of features stay smooth because geometry lives in GPU buffers and styling is expressed declaratively rather than per-element. Data-driven style expressions recolour or resize features on the GPU without touching the DOM.
The non-negotiable constraint: MapLibre GL renders in EPSG:3857, and vector tiles must be pre-projected to EPSG:3857 before serving. The engine accepts EPSG:4326 coordinates in GeoJSON sources and projects them internally, but tiled sources must already be Web Mercator. This is where your Tile vs Vector Rendering Strategies decision becomes concrete: for large data you generate vector tiles once and stream only the visible ones.
// MapLibre GL JS v4 — data-driven styling over a vector source
const map = new maplibregl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
center: [-74.01, 40.71], // [lon, lat] — MapLibre order
zoom: 11,
});
map.on('load', () => {
map.addSource('stores', { type: 'geojson', data: '/data/stores.geojson' });
map.addLayer({
id: 'stores-circles',
type: 'circle',
source: 'stores',
paint: {
// radius scales with a numeric property, entirely on the GPU
'circle-radius': ['interpolate', ['linear'], ['get', 'revenue'], 0, 3, 1e6, 14],
'circle-color': ['case', ['>', ['get', 'revenue'], 500000], '#111', '#888'],
'circle-opacity': 0.85,
},
});
// per-feature interactivity without any DOM node per feature
map.on('click', 'stores-circles', (e) => {
const p = e.features[0].properties;
new maplibregl.Popup().setLngLat(e.lngLat).setHTML(p.name).addTo(map);
});
});
Choose MapLibre GL when interactivity is per-feature and styling is dynamic. It costs more startup time than Folium and requires understanding the style specification, but it is the only one of the three built for large, individually addressable, live-styled features.
Step 5 — Choose PyDeck for GPU Aggregation
When the feature count reaches hundreds of thousands or millions and the story is about density rather than individual features, pydeck and deck.gl win outright. deck.gl batches all geometry into GPU buffers and draws it in a handful of draw calls, so a ScatterplotLayer of a million points renders at interactive frame rates where both the DOM and even per-feature vector styling would collapse. Aggregation layers — HexagonLayer, HeatmapLayer, GridLayer — bin raw points on the GPU into readable density surfaces.
import pydeck as pdk
import pandas as pd
df = pd.read_parquet("pings.parquet") # millions of rows: lon, lat, weight
scatter = pdk.Layer(
"ScatterplotLayer",
data=df,
get_position="[lon, lat]", # column order must match
get_radius=30,
radius_min_pixels=1, # stay visible when zoomed out
get_fill_color=[16, 126, 164, 160],
pickable=True,
)
hexagon = pdk.Layer(
"HexagonLayer",
data=df,
get_position="[lon, lat]",
radius=200, # meters per hex bin
elevation_scale=4,
extruded=True,
coverage=0.9,
)
deck = pdk.Deck(
layers=[hexagon], # swap to [scatter] for raw points
initial_view_state=pdk.ViewState(latitude=40.71, longitude=-74.01, zoom=10),
map_style=None, # avoid a keyed basemap dependency
)
deck.to_html("density.html", notebook_display=False) # standalone WebGL file
The radius_min_pixels and get_position accessors are the two settings that most often produce an empty map; get the column order right and give points a floor pixel size. The million-point path, with the accessor and memory details, is expanded in Rendering a Million Points with PyDeck ScatterplotLayer. Reach for PyDeck when the answer is a heatmap, a hexbin, or a point cloud — not when users need to click store number 4,182 specifically.
Step 6 — Wire the Renderer into the Python Export Pipeline
Do not hard-code one engine. Expose a single build function per engine behind a common interface so the same GeoDataFrame can be emitted as any of the three, and the engine becomes a configuration value rather than a rewrite. This keeps the Static vs Dynamic Export Methods surface identical across engines: every builder returns a path to an HTML artifact.
from pathlib import Path
from typing import Callable
import geopandas as gpd
Builder = Callable[[gpd.GeoDataFrame, Path], Path]
def build_folium(gdf: gpd.GeoDataFrame, out: Path) -> Path:
import folium
g = gdf.to_crs("EPSG:4326")
m = folium.Map(location=[g.geometry.y.mean(), g.geometry.x.mean()], zoom_start=11)
for _, r in g.iterrows():
folium.CircleMarker([r.geometry.y, r.geometry.x], radius=5).add_to(m)
m.save(out)
return out
def build_pydeck(gdf: gpd.GeoDataFrame, out: Path) -> Path:
import pydeck as pdk
g = gdf.to_crs("EPSG:4326")
df = g.assign(lon=g.geometry.x, lat=g.geometry.y).drop(columns="geometry")
layer = pdk.Layer("ScatterplotLayer", df, get_position="[lon, lat]", get_radius=30)
view = pdk.ViewState(latitude=df.lat.mean(), longitude=df.lon.mean(), zoom=10)
pdk.Deck(layers=[layer], initial_view_state=view, map_style=None).to_html(
str(out), notebook_display=False
)
return out
REGISTRY: dict[str, Builder] = {"folium": build_folium, "pydeck": build_pydeck}
def export(engine: str, gdf: gpd.GeoDataFrame, out: Path) -> Path:
"""Single entry point; engine is a config value, not a code branch."""
return REGISTRY[engine](gdf, out)
With this shape, upgrading a dashboard from Folium to PyDeck is a one-line config change plus a data-scale check, and the surrounding rebuild automation never has to know which engine ran.
Step 7 — Verify FPS and Memory
The engine is only correct if it hits its performance budget on target hardware. Verify with numbers, not vibes. The rough benchmark table below is the reference envelope — approximate interaction frame rate and browser memory tier by feature count and engine, on mid-range 2024 laptop hardware. Treat it as order-of-magnitude guidance, not a guarantee:
| Feature count | Folium (FPS / memory) | MapLibre GL (FPS / memory) | PyDeck (FPS / memory) |
|---|---|---|---|
| 1,000 | 60 / low | 60 / moderate | 60 / moderate |
| 10,000 | 20 / high | 60 / moderate | 60 / moderate |
| 100,000 | unusable / very high | 45 / high | 60 / moderate |
| 1,000,000 | fails | 20 / very high | 55 / high |
| 5,000,000 | fails | fails | 40 / high |
Measure the real numbers in the browser rather than trusting the table. Frame timing during a programmatic pan reveals whether interaction stays under the ~16 ms budget for 60 FPS:
// Drop into the exported page's console to sample interaction FPS
let frames = 0;
const start = performance.now();
function sample(t) {
frames++;
if (t - start < 3000) requestAnimationFrame(sample);
else console.log(`~${(frames / 3).toFixed(0)} FPS over 3s`);
}
requestAnimationFrame(sample);
// Read heap: performance.memory?.usedJSHeapSize (Chromium) for a memory tier
If the chosen engine misses budget on the target-hardware profile from the prerequisites, step back up: MapLibre GL that stutters at your feature count is a signal to aggregate with PyDeck; Folium that stutters is the scaling wall from Step 3.
Verification & Smoke-Test
Confirm the whole framework produced the right artifact before shipping:
- Feature parity — the rendered feature count matches the source
GeoDataFramerow count. A silent drop usually means null geometries were filtered during reprojection. - Projection sanity — the map centres on the real data, not the Gulf of Guinea. Off-target rendering is almost always an
EPSG:3857-versus-EPSG:4326mix-up. - Interaction FPS — run the sampling snippet during a pan on the lowest-spec target device and confirm it clears the budget in the benchmark table.
- Export self-containment — for static targets, open the exported
.htmlfromfile://with networking disabled; it should still render the geometry (basemap tiles excepted). - Payload budget — check the exported file size against your CDN and mobile budget; a multi-megabyte Folium file is the scaling wall in disguise.
Troubleshooting
Why does my Folium map freeze the browser above a few thousand markers?
Folium renders every feature as a Leaflet DOM node or SVG path, and the browser must lay out and hit-test each one on every frame. Past roughly 5,000 markers the main thread stalls, and because Folium inlines all coordinates as JSON, the exported HTML balloons in parallel. Short term, switch to canvas rendering with a marker-clustering plugin; properly, this is the scaling wall — move to MapLibre GL vector tiles for interactive features or a PyDeck GPU layer for density.
Why does my MapLibre GL map show a blank canvas after I add my GeoJSON source?
MapLibre GL renders in EPSG:3857 and expects GeoJSON coordinates in EPSG:4326 longitude/latitude order, which it projects internally. If your source is a projected CRS such as a UTM zone or raw EPSG:3857 meters, the features land far outside the viewport and the canvas looks empty. Reproject with gdf.to_crs("EPSG:4326") before serialising, confirm the source appears in map.getStyle().sources, and fit the camera to the data bounds to rule out a viewport problem.
Why does my PyDeck ScatterplotLayer render nothing?
Three usual suspects: a get_position accessor pointing at a missing or misordered field, a radius too small to see at the current zoom, or NaN coordinates left by an incomplete reprojection. Verify get_position="[lon, lat]" matches your column order exactly, set radius_min_pixels=1 so points stay visible when zoomed out, and drop null geometries before building the layer. Setting pickable=True and checking the tooltip confirms the data actually reached the GPU.
How do I keep a static, zero-server HTML export after moving off Folium?
Both alternatives preserve the static path. PyDeck’s Deck.to_html(..., notebook_display=False) writes a standalone WebGL page you can host on any CDN, and a MapLibre GL map can be templated into a single HTML file with an inlined style and GeoJSON. You lose nothing on the deployment side while gaining GPU or vector-tile performance; the Static vs Dynamic Export Methods guide covers the trade-offs of each output form.
Why is MapLibre GL slower to first paint than Folium on a tiny dataset?
MapLibre GL initialises a WebGL context, compiles shaders, and fetches a style document before its first frame — fixed startup cost that dominates when there is little data to draw. For a few hundred static features, Folium paints faster and is simpler to reason about. The WebGL overhead only earns its keep once feature counts or styling dynamism exceed what the DOM can handle, which is exactly the wall that Step 3 detects.
Gotchas & Edge Cases
- Coordinate order differs per engine. Folium takes
[lat, lon], MapLibre GL and PyDeck take[lon, lat]. A map that renders mirrored across the diagonal is almost always this swap, not a projection bug. - A keyed basemap can silently blank the map. Some PyDeck and MapLibre styles resolve to a provider endpoint that needs a token. Set
map_style=Nonein PyDeck or point at a self-hosted style to avoid a blank background with no error. - Vector tiles must be pre-projected. MapLibre GL will not reproject tiled sources on the client. Feeding it non-
EPSG:3857tiles produces seams and misalignment at every zoom level; generate tiles in Web Mercator up front. - GPU memory is finite and device-dependent. A million-point
ScatterplotLayerthat flies on a desktop GPU can exhaust a mid-range phone’s WebGL memory and crash the context. Always verify against the lowest-spec target profile. - Picking on aggregate layers returns bins, not features. A
HexagonLayertooltip reports the aggregated bin, not an individual record. If users must identify a specific feature, you need aScatterplotLayerwithpickable=Trueor a MapLibre feature-state layer instead. - Folium’s static export is a strength you can lose by accident. Adding a plugin that fetches data at runtime turns a self-contained file into a server-dependent one. Keep the export self-contained if a zero-server deploy is the goal.
Related
- Core Mapping Architecture & Rendering — parent guide covering the full rendering and architecture stack
- Tile vs Vector Rendering Strategies — how raster-versus-vector tiling underpins the MapLibre GL choice for large data
- CRS & Projection Management — getting coordinates into the clean EPSG:4326/3857 state every engine assumes
- Static vs Dynamic Export Methods — matching each engine’s output to a static-file or served deployment
- Migrating a Folium Map to MapLibre GL for Large Datasets — the concrete migration once you hit the Folium scaling wall
- Rendering a Million Points with PyDeck ScatterplotLayer — the GPU-aggregation deep dive for density-first dashboards