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,
)
How many decimals a map can actually showEach decimal place of a WGS 84 coordinate is labelled with the ground distance it resolves: one decimal is eleven kilometres, two is one point one kilometres, three is a hundred and eleven metres, four is eleven metres, five is one point one metres, six is eleven centimetres and seven is eleven millimetres. A marked threshold shows where a screen pixel sits for a typical web map at city zoom — somewhere between five and six decimals — and everything to the right of it is precision the display cannot represent, yet each extra decimal costs a byte per coordinate on every feature in the response. For a layer with two million vertices, trimming from the fifteen decimals a float repr emits down to six removes tens of megabytes without moving a single pixel. Below the threshold the trade reverses: rounding to four decimals visibly straightens a curve, and to three it starts merging distinct addresses.WHAT ONE DECIMAL PLACE OF LATITUDE IS WORTH123456711 km1.1 km111 m11 m1.1 m11 cm11 mmdecimalsone screen pixel at city zooma byte per coordinate, for nothingenough for anything drawnA float repr emits fifteen decimals. On a layer with two million vertices, trimming to six removes tens of megabytesfrom the response without moving a pixel on screen.Below the threshold the trade reverses: four decimals visibly straightens a curve, and three starts merging addressesthat are genuinely distinct — so the rounding belongs at serialization, not in the stored frame. ### 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")
What an invalid ring does on the way to the browserOn the left a polygon whose boundary crosses itself — a bow-tie — is drawn with its crossing point marked. GeoJSON's schema has nothing to say about this: the coordinates are well formed and the file validates, so the error is carried all the way to the client. Renderers then disagree about which side is inside, so the same feature fills differently in different browsers, and any area computed from it is meaningless. On the right the same shape after a zero-width buffer: the self-intersection is resolved into two separate valid rings that every renderer fills identically. The caption records the important caveat — the repair is a change to the geometry, not a formatting step, so it belongs upstream where it can be reviewed and logged, rather than being applied silently in the serializer where nobody will ever see that it happened.SELF-INTERSECTING — VALID GeoJSON, INVALID GEOMETRYAFTER buffer(0)the boundary crosses itselfthe coordinates are well formed, so the file validatesand the error travels to the client untouchedrenderers disagree about which side is inside — the samefeature fills differently in different browsers, and .area is meaninglesstwo valid rings — every renderer fills them the same wayThe repair changes the geometry; it is not a formatting step. Do itupstream where it can be logged and reviewed — not silently in theserializer, where nobody will ever learn that it happened. ### 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