The r/gis thread from August 2026 asked a question that comes up every few months: which Python web mapping library handles large datasets? The common answer — “use PyDeck” — is partly right but misses the key distinction that determines whether your map loads in two seconds or crashes the browser.
The real question is not “which library is faster” but “where does the data go and in what format?” Each library makes a different choice. That choice determines the practical upper limit of what you can render.
Four Libraries, Four Data Paths
Folium
Folium wraps Leaflet.js. The data path is:
GeoDataFrame → GeoJSON string → embedded in HTML as a JavaScript literal
When you call folium.GeoJson(gdf).add_to(m), Folium serializes your GeoDataFrame to a GeoJSON string and embeds it directly in the HTML output. A 50,000-feature polygon GeoDataFrame might produce a 30MB HTML file. The browser has to:
- Parse the HTML and JavaScript
- Execute the Leaflet initialization
- Parse the embedded GeoJSON object
- Create one SVG (or canvas) path element per geometry
- Layout and paint
Step 4 is where Leaflet-based renderers break down. Browsers manage SVG and canvas elements in the DOM, and the DOM cannot efficiently handle more than roughly 30,000-50,000 complex path elements. Adding 100,000 polygon rings grinds the browser to a halt — not because of file size, but because of DOM node count.
import folium
import geopandas as gpd
gdf = gpd.read_file("data/counties.shp") # 3,000 features — fine for Folium
m = folium.Map(location=[37.8, -96.9], zoom_start=4)
folium.GeoJson(
gdf,
style_function=lambda x: {
"fillColor": "#228B22",
"color": "#000000",
"weight": 1,
"fillOpacity": 0.5,
}
).add_to(m)
m.save("counties.html")
Practical upper limit: 30,000-50,000 simple features (points, lines); 5,000-10,000 complex polygons before interaction becomes sluggish.
When Folium is correct: Static HTML reports, thematic maps embedded in dashboards, data with hundreds to a few thousand features, any case where static HTML output is a requirement.
PyDeck
PyDeck binds to deck.gl, which renders via WebGL. The data path is:
GeoDataFrame → Python dict list → JSON string → ArrayBuffer in browser → GPU vertex buffer
The critical difference from Folium: deck.gl bypasses the DOM. It renders to a <canvas> element using the GPU, not to individual SVG path elements. A million scatterplot points becomes a million vertex entries in a single GPU buffer — the browser’s JavaScript thread is not involved in the rendering loop.
import pydeck
import geopandas as gpd
import pandas as pd
gdf = gpd.read_file("data/earthquakes.shp") # 500,000 point features
df = gdf.copy()
df["lon"] = gdf.geometry.x
df["lat"] = gdf.geometry.y
layer = pydeck.Layer(
"ScatterplotLayer",
data=df[["lon", "lat", "magnitude"]].to_dict("records"),
get_position=["lon", "lat"],
get_radius="magnitude * 5000",
get_fill_color=[255, 140, 0, 160],
pickable=True,
)
view = pydeck.ViewState(latitude=0, longitude=0, zoom=1)
deck = pydeck.Deck(layers=[layer], initial_view_state=view)
deck.to_html("earthquakes.html")
The catch is GeoJsonLayer specifically. When you pass a GeoDataFrame to pydeck.Layer("GeoJsonLayer", data=gdf), deck.gl triangulates the polygon geometries in JavaScript at render time — converting each GeoJSON polygon ring to triangles. For 200,000 complex polygons, this triangulation step at browser startup can take 20-30 seconds. PyDeck’s WebGL advantage is maximized with pre-triangulated data (PolygonLayer feeding pre-triangulated coordinates) or point/path data where no triangulation is needed.
Practical upper limit: 2M+ points (ScatterplotLayer), 300k-500k polygons (GeoJsonLayer, with startup cost), 1M+ paths.
When PyDeck is correct: Large point datasets, H3 hexagon aggregation (H3HexagonLayer), routing/network visualization, Streamlit or Dash web applications, any dataset where Folium lags and you need static HTML output (deck.to_html()).
Kepler.gl
Kepler.gl started as a GUI exploration tool before gaining a Python API. The data path is:
GeoDataFrame → full JSON serialization at `add_data()` → embedded in HTML
from keplergl import KeplerGl
import geopandas as gpd
map1 = KeplerGl(height=600)
gdf = gpd.read_file("data/census_blocks.shp")
map1.add_data(data=gdf, name="Census Blocks") # JSON serialization happens here
map1.save_to_html(file_name="census.html")
The add_data() call converts the entire GeoDataFrame to JSON in Python before the map object stores it. For a 200,000-feature GeoDataFrame, this single call can take 30+ seconds and allocates 500MB or more of memory. The resulting HTML file carries all of that serialized data.
The visualization capabilities beyond this are genuinely impressive: time-series animation, arc layers connecting source and destination points, hexbin aggregation, and an interactive GUI for adjusting layer settings without code. But the JSON path is load-bearing — there is no binary fast path in the Python keplergl package.
Practical upper limit: 200k-500k simple features; 100k complex polygons before add_data() becomes impractical.
When Kepler.gl is correct: Exploratory data analysis where you want a GUI to adjust layer settings interactively, time-series geospatial data (it handles temporal filtering natively), presentation-quality maps where the GUI output is the deliverable.
Lonboard
Lonboard, built by Kyle Barron, takes a fundamentally different approach to the data transfer problem. The path is:
GeoDataFrame → GeoArrow (Apache Arrow binary column buffers) → WebGL vertex buffer
GeoArrow stores coordinate arrays as contiguous binary buffers — no JSON serialization, no text parsing. A 10-million-point GeoDataFrame transfers to the browser as a typed ArrayBuffer the GPU reads directly. There is no per-feature object allocation on the JavaScript heap.
import lonboard
import geopandas as gpd
gdf = gpd.read_file("data/lidar_returns.parquet") # 5,000,000 points
layer = lonboard.ScatterplotLayer.from_geopandas(
gdf,
get_radius=3,
radius_units="pixels",
get_fill_color=[0, 150, 255, 180],
)
m = lonboard.Map(layers=[layer])
m # Display in Jupyter
The from_geopandas() call converts the GeoDataFrame geometry column to GeoArrow format using PyArrow. For 5M points, this takes 2-4 seconds. The browser receives binary buffers, not a JSON blob. No triangulation happens at render time for polygon layers — SolidPolygonLayer handles triangulation in Python before transfer.
The limitation is significant: Lonboard outputs an AnyWidget for Jupyter. There is no to_html() method. The output is an interactive widget in JupyterLab, Jupyter Notebook, Marimo, or Panel — not a standalone HTML file. If your workflow ends with a static file that someone opens in a browser, Lonboard is not the right tool.
Practical upper limit: 10M+ points; 2M+ polygons (SolidPolygonLayer, pre-triangulated). The GPU is the real limit.
When Lonboard is correct: Very large datasets that exceed PyDeck’s practical limits; point clouds, lidar returns, census block-level analysis for the whole US; any case where you’re in Jupyter and don’t need static HTML output.
The GeoJSON Bottleneck
The question of “when does GeoJSON slow down my map” has a precise answer. GeoJSON is a text format. For a dataset with N point features, the GeoJSON representation is roughly:
{"type":"FeatureCollection","features":[
{"type":"Feature","geometry":{"type":"Point","coordinates":[lon,lat]},"properties":{...}},
...
]}
Each feature entry is approximately 80-200 bytes of text for a simple point with a few properties. At 1M features:
- 100-200MB GeoJSON file (text)
- Browser must parse that text into a JavaScript object tree
- JSON.parse() for 200MB takes 3-8 seconds in Chrome
- The resulting object tree uses 2-4× the memory of the raw text
This is why GeoJSON was not designed as an interchange format for large datasets — it is a good format for small-to-medium web API payloads (under 10MB), not for million-feature analysis layers.
The format alternative for large datasets that need web visualization is FlatGeobuf — binary, supports HTTP range requests, and a Lonboard MapLibre GL JS tile source can stream it without loading the full file. But that requires a tile server, not a Jupyter notebook.
Decision Framework
| Folium | PyDeck | Kepler.gl | Lonboard | |
|---|---|---|---|---|
| Output | Static HTML | Static HTML / Jupyter | Static HTML / Jupyter | Jupyter only |
| Data transfer | GeoJSON in HTML | JSON → buffer | JSON in HTML | Arrow binary |
| Max features (points) | ~50k | ~2M | ~500k | 10M+ |
| Max features (polygons) | ~10k | ~300k | ~150k | ~2M |
| GPU rendering | No | Yes | No | Yes |
| Static HTML export | Yes | Yes | Yes | No |
| Interactive GUI | No | No | Yes | No |
| Time animation | Via plugins | Via TripsLayer | Native | No |
| Notebook required | No | No | No | Yes |
The rule of thumb: choose by output format first, data size second.
If you need a standalone HTML file, pick Folium (small data) or PyDeck (large data). If you’re in Jupyter and your dataset has more than 1M features, use Lonboard. If you need a rich GUI for exploration or time-series playback, use Kepler.gl.
Working with Format Conversions
The library choice affects which input formats are practical. Folium and Kepler.gl both trigger a GeoJSON serialization step regardless of your source format — reading a GeoPackage still produces GeoJSON at the point it enters the browser. PyDeck accepts Python dicts and pandas DataFrames, bypassing the GeoJSON path for ScatterplotLayer and PolygonLayer. Lonboard accepts GeoPandas DataFrames and converts to Arrow in Python — the source format is irrelevant once it’s in a GeoDataFrame.
The implication for format conversion: if you’re receiving data as Shapefile or GeoPackage and plan to visualize it in PyDeck or Lonboard, GDAL/OGR conversion to GeoParquet gives you an efficient read path via GeoParquet → GeoPandas → Arrow. The GeoParquet vs GeoPackage decision tree covers when GeoParquet pays off vs simpler formats.
GeoConvert handles the Shapefile and GeoJSON ends of this — converting GeoJSON to Shapefile for QGIS workflows, or Shapefile to GeoJSON for web APIs. The visualization step downstream sits outside that scope, but the format you start with determines which Python mapping library is viable.
What the “GPU-Accelerated” Claim Actually Means
Both PyDeck and Lonboard advertise GPU rendering. The distinction matters:
- PyDeck GeoJsonLayer: triangulates polygons in JavaScript (CPU) at render time, then uploads to GPU. Large polygon counts still create JavaScript CPU pressure.
- Lonboard SolidPolygonLayer: triangulates polygons in Python (CPU) via
earcutbefore sending binary to the browser. The browser receives pre-triangulated triangles as Arrow buffers. Zero JavaScript-side triangulation. - PyDeck ScatterplotLayer / PathLayer: no triangulation needed. Data goes CPU → GPU directly. Both PyDeck and Lonboard are equivalent here.
“GPU-accelerated” for polygon layers specifically means Lonboard wins at scale because the triangulation work happens in Python (fast), not in the browser’s single-threaded JavaScript runtime.