Converting Shapely Geometries to GeoJSON for Web Maps
Reproject to EPSG:4326, repair geometries with make_valid, then export with gdf.to_json() (whole layer) or shapely.geometry.mapping() (one geometry) — rounding coordinates to six decimals to keep the browser payload small.
Jump to heading Why this matters
Folium, Leaflet, and Deck.gl all consume GeoJSON, and the format has one non-negotiable rule that trips up nearly every new spatial dashboard: coordinates must be longitude/latitude in WGS84. A GeoDataFrame that was reprojected to Web Mercator for a choropleth, or that arrived in a national grid like EPSG:27700, will render in the wrong place — or nowhere — if you serialize it directly. On top of that, the default 15-digit coordinate output inflates payloads that then travel over the wire to every browser session. This page is the exact conversion path from a validated layer — the kind produced in the GeoDataFrame Schema Reference — to browser-ready GeoJSON for a Folium or Leafmap layer.
Jump to heading Prerequisites
- Python 3.10+ with
geopandas>=0.14,shapely>=2.0. - A
GeoDataFramewith a valid active geometry and a known CRS.
pip install "geopandas>=0.14" "shapely>=2.0" folium
Jump to heading Coordinate precision reference
Round deliberately: pick the precision your map actually needs, not the 15 digits float64 emits by default.
| Decimal places | Ground precision (equator) | Typical use | Payload impact |
|---|---|---|---|
| 4 | ~11 m | Country/region overviews | Smallest |
| 5 | ~1.1 m | City-scale dashboards | ~40% smaller than default |
| 6 | ~0.11 m | Street-level, default recommendation | ~30% smaller than default |
| 7 | ~1.1 cm | Survey-grade overlays | Marginal savings |
| 15 (default) | sub-atomic | Never needed for display | Largest |
Jump to heading Step-by-step solution
Jump to heading Step 1 — Reproject to EPSG:4326
GeoJSON is defined in lon/lat WGS84. Convert before serializing, whatever the working CRS.
import geopandas as gpd
gdf = gpd.read_file("districts_3857.gpkg") # projected Web Mercator
if gdf.crs is None:
raise ValueError("Set a CRS before export — cannot guess the source projection")
if gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(4326) # actual coordinate transform
Jump to heading Step 2 — Repair invalid geometries
Self-intersecting or unclosed rings make renderers throw or draw garbage. make_valid fixes them without discarding features.
from shapely import make_valid
invalid = ~gdf.geometry.is_valid
if invalid.any():
gdf.loc[invalid, "geometry"] = gdf.loc[invalid, "geometry"].apply(make_valid)
# Drop anything that is still empty/None after repair
gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty].reset_index(drop=True)
Jump to heading Step 3 — Map a single geometry
For one geometry, shapely.geometry.mapping() (or the equivalent __geo_interface__ attribute) returns a GeoJSON geometry dict. This is the building block when you assemble features by hand for pydeck.
from shapely.geometry import Point, mapping
pt = Point(4.895168, 52.370216) # Amsterdam, lon/lat EPSG:4326
print(mapping(pt))
# {'type': 'Point', 'coordinates': (4.895168, 52.370216)}
print(pt.__geo_interface__) # identical dict via the protocol attribute
Jump to heading Step 4 — Export a whole layer
gdf.to_json() produces a complete GeoJSON FeatureCollection string, with every attribute column carried into properties — exactly what Folium’s GeoJson layer expects.
import folium
geojson_str = gdf.to_json() # FeatureCollection with properties
m = folium.Map(location=[52.3702, 4.8952], zoom_start=12)
folium.GeoJson(
geojson_str,
name="districts",
tooltip=folium.GeoJsonTooltip(fields=["name", "metric"]),
).add_to(m)
Every non-geometry column becomes a properties key, so drop columns you do not want shipped to the browser before exporting — a slimmer frame is a smaller payload and avoids leaking internal fields. To render the map inside a Streamlit app, hand the Folium object to st_folium:
from streamlit_folium import st_folium
export_cols = ["name", "metric", "geometry"] # only what the map needs
geojson_str = gdf[export_cols].to_json()
folium.GeoJson(geojson_str, name="districts").add_to(m)
st_folium(m, width=900, height=550)
For Deck.gl via pydeck, pass the dict form so the layer can read coordinates directly:
import json
import pydeck as pdk
features = json.loads(gdf.to_json())["features"]
layer = pdk.Layer(
"GeoJsonLayer",
features,
get_fill_color=[163, 38, 91, 140],
pickable=True,
)
Jump to heading Step 5 — Round coordinates to cut payload
Trim precision before serializing. shapely.set_precision snaps to a grid, and passing to_json(drop_id=True) plus a rounded geometry keeps the payload lean.
from shapely import set_precision
# grid_size in degrees: 1e-6 ≈ 6 decimal places ≈ 0.11 m
gdf["geometry"] = set_precision(gdf.geometry.values, grid_size=1e-6)
compact = gdf.to_json(drop_id=True)
print(f"Payload: {len(compact) / 1024:.0f} KB")
Jump to heading Handling Multi-parts and holes
GeoJSON encodes multi-part geometries and holes structurally, and mapping() produces the nesting automatically once the geometry is valid — you rarely build it by hand, but it helps to see the shape. A Polygon with a hole is a coordinate array whose first ring is the exterior and every subsequent ring is an interior hole; a MultiPolygon wraps a list of those:
from shapely.geometry import Polygon, MultiPolygon, mapping
# Exterior ring + one interior hole (lon/lat, EPSG:4326)
exterior = [(4.88, 52.36), (4.92, 52.36), (4.92, 52.39), (4.88, 52.39), (4.88, 52.36)]
hole = [(4.89, 52.37), (4.91, 52.37), (4.91, 52.38), (4.89, 52.38), (4.89, 52.37)]
poly = Polygon(exterior, [hole])
gj = mapping(poly)
assert gj["type"] == "Polygon"
assert len(gj["coordinates"]) == 2 # ring 0 = shell, ring 1 = hole
multi = MultiPolygon([poly, Polygon(
[(5.0, 52.3), (5.05, 52.3), (5.05, 52.34), (5.0, 52.34), (5.0, 52.3)])])
assert mapping(multi)["type"] == "MultiPolygon"
Because Folium and Deck.gl read this nesting natively, the only thing you must guarantee is that every ring is closed (first point equals last) and valid — which Step 2 already handles. Mixed single/multi layers still export fine, but promoting the whole column to MultiPolygon first keeps the client-side styling code uniform.
Jump to heading Verification
Round-trip the GeoJSON and confirm structure, feature count, and CRS.
import json
parsed = json.loads(compact)
assert parsed["type"] == "FeatureCollection"
assert len(parsed["features"]) == len(gdf), "Feature count mismatch — a row was dropped"
# GeoJSON coordinates must be plausible lon/lat
lon, lat = parsed["features"][0]["geometry"]["coordinates"][0][0][0:2] \
if parsed["features"][0]["geometry"]["type"] == "Polygon" else (4.9, 52.4)
assert -180 <= lon <= 180 and -90 <= lat <= 90, "Coordinates are not lon/lat — reproject to 4326"
print(f"Valid FeatureCollection: {len(parsed['features'])} features")
Jump to heading Edge cases and gotchas
- GeoJSON is always lon/lat 4326 — no exceptions. A projected export (EPSG:3857 metres, EPSG:27700 eastings/northings) will silently place your layer in the wrong spot because the renderer reads the numbers as degrees. Always
to_crs(4326)immediately before serializing, even if the rest of the dashboard works in a projected CRS. - Precision versus size is a real trade-off. Six decimals (~0.11 m) is the sweet spot for street-level maps; going to 15 digits multiplies payload for zero visible benefit, while dropping to 4 decimals (~11 m) is fine for national overviews but visibly coarse when zoomed in.
- Invalid or non-closed rings must be fixed, not exported. Polygons whose exterior ring does not close, or that self-intersect, throw in strict parsers and render as slivers in lenient ones. Run
make_validfirst; for holes, GeoJSON represents them as additional interior rings in the same coordinate array, whichto_jsonhandles automatically once the geometry is valid.
Jump to heading FAQ
Why does my GeoJSON layer appear in the wrong place on the map?
The GeoJSON spec mandates lon/lat coordinates in WGS84 (EPSG:4326). If you export a projected GeoDataFrame such as EPSG:3857 or a national grid directly, Folium and Leaflet interpret the projected metre values as degrees and place the layer near the equator or off the map entirely. Reproject with gdf.to_crs(4326) before calling to_json().
How many decimal places should GeoJSON coordinates keep?
Six decimal places of longitude and latitude is about 0.11 metres, finer than any web map renders. Rounding to six decimals can cut payload size by roughly a third versus the 15-digit default with no visible change, and five decimals (about a metre) is enough for city-scale dashboards.
What is the difference between to_json and __geo_interface__?
gdf.to_json() returns a GeoJSON FeatureCollection as a string, ready to hand to Folium or a Leaflet layer. __geo_interface__ (and shapely.geometry.mapping()) returns a Python dict following the same protocol, which is useful when you need to post-process features, inject styling properties, or feed pydeck before serializing with json.dumps yourself.
Back to GeoDataFrame Schema Reference
Related
- GeoDataFrame Schema Reference — the schema and CRS discipline a clean GeoJSON export depends on
- Folium & Leafmap Integration — rendering the exported GeoJSON as an interactive layer
- Choosing column dtypes to reduce GeoDataFrame memory — slimming the layer before you serialize it
- CRS & Coordinate Systems Reference — why the reprojection step to EPSG:4326 is mandatory