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 GeoDataFrame with a valid active geometry and a known CRS.
bash
pip install "geopandas>=0.14" "shapely>=2.0" folium
Shapely geometry to GeoJSON conversion pipelineFour stages left to right. Stage one is a GeoDataFrame in a projected CRS such as EPSG:3857. An arrow labelled to_crs(4326) leads to stage two, reproject to lon/lat WGS84. An arrow labelled make_valid leads to stage three, repair invalid rings. An arrow labelled round 6 decimals leads to stage four, a compact GeoJSON FeatureCollection. A final arrow feeds three renderers listed as Folium, Leaflet, and Deck.gl.GeoDataFramecrs = EPSG:3857to_crsReprojectEPSG:4326 lon/latvalidRepair + roundmake_valid · 6 dpGeoJSONFeatureCollectionFolium · Leaflet · Deck.glshapely / GeoDataFrame → browser-ready GeoJSONGeoJSON is always lon/lat WGS84 — reproject first, serialize last

Jump to heading Coordinate precision reference

Round deliberately: pick the precision your map actually needs, not the 15 digits float64 emits by default.

Decimal placesGround precision (equator)Typical usePayload impact
4~11 mCountry/region overviewsSmallest
5~1.1 mCity-scale dashboards~40% smaller than default
6~0.11 mStreet-level, default recommendation~30% smaller than default
7~1.1 cmSurvey-grade overlaysMarginal savings
15 (default)sub-atomicNever needed for displayLargest

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.

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

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

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

python
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:

python
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:

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

python
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:

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

python
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_valid first; for holes, GeoJSON represents them as additional interior rings in the same coordinate array, which to_json handles 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