Part of the Choosing a Renderer: Folium vs MapLibre GL vs PyDeck guide.
Operative rule: feed ScatterplotLayer get_position the EPSG:4326 lon/lat columns (longitude first) — never pre-projected EPSG:3857 meters; Deck.gl does the Mercator projection on the GPU.
How It Works
pydeck is the Python binding for Deck.gl, a WebGL framework built to draw very large geospatial datasets. Where a Leaflet-based tool creates one DOM node per feature and chokes in the tens of thousands — the reason covered in migrating a Folium map to MapLibre GL for large datasets — pydeck uploads all the point coordinates into GPU buffers once and draws them as instanced primitives. A ScatterplotLayer renders roughly a million points at interactive frame rates on ordinary hardware because the per-point work happens in parallel on the GPU, not on the JavaScript main thread. Deciding when this engine is the right pick is the subject of the renderer selection: Folium vs MapLibre GL vs PyDeck guide.
Two contracts make this work. First, coordinates: get_position must reference the longitude and latitude columns, longitude first, in EPSG:4326 degrees. Deck.gl runs the Web Mercator projection in the vertex shader, so if you hand it EPSG:3857 metre values they are read as absurd degrees and every point lands off the map — the same units trap described in CRS & Projection Management. Second, sizing: get_radius is measured in metres, which means a fixed radius shrinks below one pixel and disappears when you zoom out. radius_min_pixels clamps the on-screen size so points remain visible at every zoom. Once the layer is built, pdk.Deck(...).to_html() serialises the whole scene — data inlined — into a standalone file, the export pattern detailed in exporting PyDeck visualizations to standalone HTML.
Production-Ready Implementation
The script builds a ScatterplotLayer from a pandas frame of about a million rows, tunes the pixel-radius clamps, and exports a standalone file. For a geopandas frame, extract .x/.y from the point geometry into plain columns first so Deck.gl receives numbers, not shapely objects.
from __future__ import annotations
from pathlib import Path
import numpy as np
import pandas as pd
import pydeck as pdk
def build_scatter_deck(df: pd.DataFrame, output: Path) -> Path:
"""
Render ~1M points with a ScatterplotLayer and export standalone HTML.
df must contain float columns 'lon' and 'lat' in EPSG:4326 degrees.
"""
# Guard the coordinate contract: degrees, not projected meters.
assert df["lon"].between(-180, 180).all(), "lon out of degree range — projected meters?"
assert df["lat"].between(-90, 90).all(), "lat out of degree range — projected meters?"
layer = pdk.Layer(
"ScatterplotLayer",
data=df, # pydeck reads the DataFrame directly
get_position=["lon", "lat"], # EPSG:4326, LONGITUDE FIRST
get_fill_color=[255, 140, 0, 140],
get_radius="magnitude", # data-driven radius, in METERS
radius_min_pixels=1, # never smaller than 1px when zoomed out
radius_max_pixels=8, # never larger than 8px when zoomed in
pickable=False, # disable hit-testing at 1M pts for speed
)
view_state = pdk.ViewState(
longitude=float(df["lon"].mean()),
latitude=float(df["lat"].mean()),
zoom=3,
pitch=0,
)
deck = pdk.Deck(
layers=[layer],
initial_view_state=view_state,
map_style="https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
tooltip=False,
)
output.parent.mkdir(parents=True, exist_ok=True)
# notebook_display=False → clean standalone file that opens in any browser.
deck.to_html(str(output), notebook_display=False)
return output.resolve()
if __name__ == "__main__":
rng = np.random.default_rng(42)
n = 1_000_000
sample = pd.DataFrame(
{
"lon": rng.uniform(-125, -66, n), # CONUS longitudes
"lat": rng.uniform(24, 49, n), # CONUS latitudes
"magnitude": rng.uniform(50, 400, n),
}
)
path = build_scatter_deck(sample, Path("dist/points.html"))
print(f"Exported: {path}")
Alternative Variants
Aggregate density with HexagonLayer
At a million points the raw scatter becomes an opaque blob in dense regions. HexagonLayer bins points into hexagonal cells and aggregates counts on the GPU, revealing density structure and cutting overdraw. The coordinate contract is identical — degrees in get_position:
hex_layer = pdk.Layer(
"HexagonLayer",
data=df,
get_position=["lon", "lat"], # still EPSG:4326, lon first
radius=2000, # hexagon radius in METERS
elevation_scale=20,
elevation_range=[0, 3000],
extruded=True, # 3D columns encode count per cell
coverage=0.9,
pickable=True,
)
deck = pdk.Deck(
layers=[hex_layer],
initial_view_state=pdk.ViewState(longitude=-96.0, latitude=38.5, zoom=3, pitch=40),
map_style="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
)
Layer parameter reference
| Parameter | Layer | Units / type | Purpose |
|---|---|---|---|
get_position |
both | [lon, lat] degrees |
Point location, EPSG:4326 |
get_radius |
ScatterplotLayer | meters (number or column) | Per-point real-world size |
radius_min_pixels |
ScatterplotLayer | pixels | Floor so points never vanish |
radius_max_pixels |
ScatterplotLayer | pixels | Cap so points never bloat |
radius |
HexagonLayer | meters | Bin cell size |
extruded |
HexagonLayer | bool | 3D columns encode aggregated count |
pickable |
both | bool | Enable hover/click hit-testing |
Verification Steps
- Degree-range assertion — the
lon.between(-180, 180)/lat.between(-90, 90)guards should pass; a failure means projected meters leaked in. - Column dtype — confirm
lon/latare float columns, notobject(shapely geometry). Forgeopandas, materialisedf["lon"] = gdf.geometry.x. - Visibility on zoom-out — export, open the file, and zoom fully out; points should stay visible thanks to
radius_min_pixels. - Frame rate — pan and zoom the exported map; a million points should stay smooth. If not, disable
pickableand preferHexagonLayer. - File smoke-test — serve
dist/points.htmlwithpython -m http.serverand confirm the map renders with no blank canvas.
Common Errors & Fixes
Every point appears in the ocean off West Africa or nowhere at all
get_position received EPSG:3857 metre values (six- to seven-digit numbers) interpreted as degrees, sending points near (0, 0) or off-map. Fix: pass EPSG:4326 lon/lat columns; if the source is projected, reproject to EPSG:4326 first and extract lon/lat before building the layer.
Points disappear when zooming out
get_radius is in metres, so a small real-world radius drops below one pixel at low zoom. Fix: set radius_min_pixels to 1–3 so points remain visible, and radius_max_pixels to cap size when zoomed in.
TypeError: Object of type ... is not JSON serializable
A geopandas geometry column or NumPy scalar type was passed straight to pdk.Layer. The serialiser needs plain floats. Fix: extract df["lon"] = gdf.geometry.x and df["lat"] = gdf.geometry.y, drop the geometry column, and cast values with .astype(float).
Interaction is sluggish at a million points
pickable=True forces per-frame hit-testing across every point. Fix: set pickable=False for dense scatter layers, or switch to HexagonLayer so the GPU aggregates into a far smaller number of pickable cells.
Related
- Choosing a Renderer: Folium vs MapLibre GL vs PyDeck — parent guide on selecting the engine for your data volume
- Migrating a Folium Map to MapLibre GL for Large Datasets — the polygon-oriented sibling to this point-oriented approach
- Exporting PyDeck Visualizations to Standalone HTML — the
to_html()export step in depth - Tile vs Vector Rendering Strategies — when tiling beats drawing raw points client-side