How to Prepare a GeoDataFrame for the Web
On this page
Problem statement
A GeoDataFrame that works in Python needs six specific changes before it is fit to send to a browser, and skipping any of them produces a map that is slow, wrong or blank.
Measured on Leiden's 58,879 OpenStreetMap buildings:
import gzip
import os
import geopandas as gpd
gdf = gpd.read_parquet("buildings.parquet")
text = gdf.to_json()
print(f"GeoParquet, as analysed {os.path.getsize('buildings.parquet') / 1e6:6.2f} MB")
print(f"GeoJSON, as written {len(text) / 1e6:6.2f} MB gzipped {len(gzip.compress(text.encode(), 6)) / 1e6:.2f} MB")
GeoParquet, as analysed 5.63 MB
GeoJSON, as written 20.80 MB gzipped 2.98 MB
and one zoom-15 vector tile of central Leiden measured on the size page was 46.89 kB gzipped.
The last line is the destination. Getting there is a checklist rather than a single operation.
Quick answer
import math
def prepare_for_web(gdf, keep_columns=(), zoom=14, decimals=6,
min_visible_px=2.0):
"""The six changes, in the order that matters."""
utm = gdf.to_crs(gdf.estimate_utm_crs()) # 1. project to measure
lat = float(gdf.geometry.centroid.y.mean())
m_per_px = 156543.03392 * math.cos(math.radians(lat)) / (2 ** zoom)
utm = utm[utm.geometry.is_valid & ~utm.geometry.is_empty] # 2. valid only
utm = utm[utm.geometry.area >= (min_visible_px * m_per_px) ** 2] # 3. visible
utm["geometry"] = utm.geometry.simplify(m_per_px,
preserve_topology=True) # 4. simplify
out = utm.to_crs(4326) # 5. WGS 84 for the web
out = out[list(keep_columns) + ["geometry"]] # 6. trim attributes
out["geometry"] = out.geometry.set_precision(10 ** -decimals)
return out
web = prepare_for_web(gdf, keep_columns=("building",), zoom=14)
print(f"{len(gdf):,} -> {len(web):,} features, {len(gzip.compress(web.to_json().encode(), 6)) / 1e6:.2f} MB gzipped")
58,879 -> 3,603 features, 0.13 MB gzipped
The order is not arbitrary: project before measuring, select before simplifying, simplify before reprojecting, round last.
Metric decisions, valid geometry, selection, then WGS 84
1. Reproject to a metric CRS to make decisions
Visibility thresholds and simplification tolerances are distances. In degrees they mean nothing consistent — one degree of longitude is 67 km at 53° north and 111 km at the equator.
utm = gdf.to_crs(gdf.estimate_utm_crs())
print(utm.crs.name, "| first vertex", [round(v) for v in utm.geometry.iloc[0].exterior.coords[0]])
for label, frame in (("EPSG:4326", gdf), (utm.crs.name, utm), ("EPSG:3857", gdf.to_crs(3857))):
x0, y0, x1, y1 = frame.total_bounds
print(f" {label:22} x {x0:,.2f} … {x1:,.2f} y {y0:,.2f} … {y1:,.2f}")
WGS 84 / UTM zone 31N | first vertex [600863, 5777531]
EPSG:4326 x 4.44 … 4.53 y 52.13 … 52.18
WGS 84 / UTM zone 31N x 598,533.05 … 604,679.09 y 5,776,181.97 … 5,782,581.92
EPSG:3857 x 494,296.79 … 504,271.43 y 6,823,131.29 … 6,833,485.06
2. Fix or remove invalid geometry
An invalid polygon may render as nothing, as a filled blob, or as an error, depending on the client. Browsers are less forgiving than desktop GIS.
invalid = ~gdf.geometry.is_valid
print(f"{invalid.sum()} invalid of {len(gdf):,}")
if invalid.any():
gdf.loc[invalid, "geometry"] = gdf.loc[invalid, "geometry"].make_valid()
0 invalid of 58,879
Empty and null geometries should be dropped rather than fixed — they serialise to null in GeoJSON and many clients throw on them.
3. Select what will be visible
At zoom 14 and Leiden's 52.2° north, one pixel is 5.9 m, so a two-pixel threshold is about 138 m². That keeps 6.1% of the buildings; at zoom 10 it keeps one of 58,879 (Example 3).
Selection is worth orders of magnitude more than any other step. See Generalisation for zoom levels explained.
4. Simplify to about one pixel
Finer than a pixel is invisible. Coarser starts to distort shapes.
utm["geometry"] = utm.geometry.simplify(m_per_px, preserve_topology=True)
Example 1 reports how many vertices survive: on the 3,603 buildings large enough to be kept at zoom 14, simplifying at 5.9 m kept 31.7% of their vertices. It cannot take a small polygon below the few vertices a valid shape needs, which is why selection, not simplification, removes the bulk.
5. Reproject to EPSG:4326
GeoJSON is defined in WGS 84 longitude and latitude, and every web map library assumes it. Writing a projected GeoJSON produces coordinates in the hundreds of thousands, which most clients place off the map without complaint.
6. Trim attributes and round coordinates
out = gdf.to_crs(4326)[["name", "building", "geometry"]]
out["geometry"] = out.geometry.set_precision(1e-6)
print(f"{out.geometry.is_empty.sum()} emptied by rounding")
0 emptied by rounding
Six decimal places is about 11 cm and cost nothing here. Do not go further for footprints: at four decimals, 32,384 of these buildings collapsed to empty geometries.
A full preparation with a report
The data used on this page
The layer is every building in Leiden in OpenStreetMap — about 59,000 footprints with their building type and, where mapped, a name, height and number of levels — fetched with OSMnx through an Overpass mirror and saved as GeoParquet in EPSG:4326. The OpenStreetMap answers are read as the Overpass mirror gave them on 10 October 2026.
import io
import os
import requests
import zipfile
import geopandas as gpd
import osmnx as ox
# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-prepare-geodataframe-for-web-2026-10-10.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api" # overpass-api.de mirror
ox.settings.overpass_rate_limit = False
if not os.path.exists("buildings.parquet"):
found = ox.features_from_place("Leiden, Netherlands", {"building": True}).reset_index()
found = found[found.geom_type.isin(["Polygon", "MultiPolygon"])]
keep = [c for c in ("osmid", "building", "name", "height", "building:levels") if c in found.columns]
found[keep + ["geometry"]].rename(columns={"building:levels": "levels"}).to_parquet("buildings.parquet")
buildings = gpd.read_parquet("buildings.parquet")
print(f"{len(buildings):,} OSM buildings in Leiden, EPSG:{buildings.crs.to_epsg()}; "
f"columns {[c for c in buildings.columns if c != 'geometry']}")
58,879 OSM buildings in Leiden, EPSG:4326; columns ['building', 'name', 'height', 'levels']
Example 1 — the full preparation, with a report
import math
import geopandas as gpd
def prepare_for_web(gdf, keep_columns=(), zoom=14, decimals=6,
min_visible_px=2.0, report=True):
"""Project, validate, select, simplify, reproject, trim, round."""
start_features = len(gdf)
lat = float(gdf.geometry.centroid.y.mean())
m_per_px = 156543.03392 * math.cos(math.radians(lat)) / (2 ** zoom)
utm = gdf.to_crs(gdf.estimate_utm_crs())
empty = utm.geometry.isna() | utm.geometry.is_empty
if empty.any():
utm = utm[~empty]
invalid = ~utm.geometry.is_valid
if invalid.any():
utm.loc[invalid, "geometry"] = utm.loc[invalid, "geometry"].make_valid()
utm = utm[utm.geometry.geom_type.isin(
["Polygon", "MultiPolygon", "LineString", "MultiLineString",
"Point", "MultiPoint"])]
min_area = (min_visible_px * m_per_px) ** 2
if utm.geom_type.isin(["Polygon", "MultiPolygon"]).all():
utm = utm[utm.geometry.area >= min_area]
before_vertices = _count_vertices(utm)
utm["geometry"] = utm.geometry.simplify(m_per_px, preserve_topology=True)
after_vertices = _count_vertices(utm)
out = utm.to_crs(4326)
missing = [c for c in keep_columns if c not in out.columns]
if missing:
raise KeyError(f"columns not present: {missing}")
out = out[list(keep_columns) + ["geometry"]]
out["geometry"] = out.geometry.set_precision(10 ** -decimals)
if report:
import gzip
text = out.to_json()
print(f" features {start_features:,} -> {len(out):,} "
f"({len(out) / start_features:.1%})")
print(f" vertices {before_vertices:,} -> {after_vertices:,} "
f"({after_vertices / max(before_vertices, 1):.1%})")
print(f" empty {int(empty.sum())}, invalid {int(invalid.sum())} "
f"repaired")
print(f" {len(text) / 1e6:.2f} MB, "
f"{len(gzip.compress(text.encode(), 6)) / 1e6:.2f} MB gzipped")
return out
def _count_vertices(gdf):
total = 0
for geom in gdf.geometry:
if geom is None or geom.is_empty:
continue
if geom.geom_type == "Polygon":
total += len(geom.exterior.coords)
elif geom.geom_type == "MultiPolygon":
total += sum(len(p.exterior.coords) for p in geom.geoms)
elif geom.geom_type in ("LineString", "MultiLineString"):
total += len(geom.coords) if geom.geom_type == "LineString" \
else sum(len(p.coords) for p in geom.geoms)
return total
for zoom in (14, 10):
print(f"zoom {zoom}")
prepare_for_web(gpd.read_parquet("buildings.parquet"), keep_columns=("building",), zoom=zoom)
zoom 14
features 58,879 -> 3,603 (6.1%)
vertices 63,145 -> 20,001 (31.7%)
empty 0, invalid 0 repaired
0.94 MB, 0.13 MB gzipped
zoom 10
features 58,879 -> 1 (0.0%)
vertices 95 -> 13 (13.7%)
empty 0, invalid 0 repaired
0.01 MB, 0.00 MB gzipped
Reporting both the feature and vertex reductions makes it obvious which lever did the work — and for small polygons it is always the feature count.
Example 2 — checking a layer against a browser budget
import gzip
def web_readiness(gdf, budget_mb=2.0, budget_features=10_000):
"""Will this layer be usable in a browser as a single file?"""
checks = []
if gdf.crs is None:
checks.append("no CRS set — clients cannot place the data")
elif gdf.crs.to_epsg() != 4326:
checks.append(f"CRS is {gdf.crs.to_string()}, not EPSG:4326")
invalid = int((~gdf.geometry.is_valid).sum())
if invalid:
checks.append(f"{invalid:,} invalid geometries")
empty = int((gdf.geometry.isna() | gdf.geometry.is_empty).sum())
if empty:
checks.append(f"{empty:,} null or empty geometries")
text = gdf.to_json()
compressed = len(gzip.compress(text.encode(), 6)) / 1e6
if compressed > budget_mb:
checks.append(f"{compressed:.2f} MB gzipped exceeds {budget_mb} MB")
if len(gdf) > budget_features:
checks.append(f"{len(gdf):,} features will stall the main thread")
wide = [c for c in gdf.columns if c != "geometry"]
if len(wide) > 8:
checks.append(f"{len(wide)} attribute columns — send only what is styled")
print(f" {len(gdf):,} features, {compressed:.2f} MB gzipped")
for issue in checks:
print(f" ! {issue}")
if not checks:
print(" ready for the web")
return checks
print("as analysed")
web_readiness(gpd.read_parquet("buildings.parquet"))
print("prepared for zoom 14")
web_readiness(prepare_for_web(gpd.read_parquet("buildings.parquet"), keep_columns=("building",),
zoom=14, report=False))
as analysed
58,879 features, 2.98 MB gzipped
! 2.98 MB gzipped exceeds 2.0 MB
! 58,879 features will stall the main thread
prepared for zoom 14
3,603 features, 0.13 MB gzipped
ready for the web
Example 3 — one export per zoom level
import math
import os
def export_zoom_levels(gdf, out_dir, zooms=range(10, 17), keep_columns=(),
decimals=6):
"""A separate, appropriately generalised file per zoom."""
os.makedirs(out_dir, exist_ok=True)
lat = float(gdf.geometry.centroid.y.mean())
for zoom in zooms:
m_per_px = 156543.03392 * math.cos(math.radians(lat)) / (2 ** zoom)
out = prepare_for_web(gdf, keep_columns=keep_columns, zoom=zoom,
decimals=decimals, report=False)
path = os.path.join(out_dir, f"z{zoom}.geojson")
out.to_file(path, driver="GeoJSON")
print(f" z{zoom:<3} {m_per_px:8.2f} m/px {len(out):7,} features "
f"{os.path.getsize(path) / 1e6:7.2f} MB {path}")
export_zoom_levels(gpd.read_parquet("buildings.parquet"), "zooms", keep_columns=("building",))
z10 93.79 m/px 1 features 0.01 MB zooms/z10.geojson
z11 46.89 m/px 11 features 0.02 MB zooms/z11.geojson
z12 23.45 m/px 211 features 0.07 MB zooms/z12.geojson
z13 11.72 m/px 947 features 0.27 MB zooms/z13.geojson
z14 5.86 m/px 3,603 features 0.96 MB zooms/z14.geojson
z15 2.93 m/px 33,439 features 8.35 MB zooms/z15.geojson
z16 1.47 m/px 43,752 features 11.43 MB zooms/z16.geojson
Serving a different file per zoom range is the poor relation of vector tiles and works well for a handful of levels. Beyond that, generate tiles — the client then loads only the current view rather than the whole level.
Why the order of operations matters
Why the order of operations matters
Each step changes what the next one sees.
Project first, because every threshold afterwards is a distance. A tolerance in degrees is anisotropic — at 53° north, one degree east-west is 67 km and north-south 111 km, so a "0.001 degree" tolerance is 1.7 times coarser in one direction.
Select before simplifying, because simplifying features you are about to drop is wasted work.
Simplify before reprojecting to 4326, because the tolerance is in metres.
Round last, because simplification would otherwise reintroduce full-precision coordinates.
Why EPSG:4326 is not negotiable
The GeoJSON specification defines coordinates as WGS 84 longitude and latitude. Clients do not read a CRS from the file — they assume it.
A GeoJSON written in UTM has coordinates like the ones printed under step 1 — hundreds of thousands of metres east, millions north. A web map interprets those as a longitude and latitude, which is off the world. Some clients silently show nothing; some throw.
Web Mercator is used internally for tiling, but the data you send is 4326.
Why validity matters more on the web
Desktop GIS is tolerant: it repairs, buffers by zero, or draws something approximate.
Browser rendering libraries are not. An invalid polygon can produce a filled-screen artefact, a missing feature, or a thrown exception that stops the whole layer from rendering. A null geometry frequently throws.
Validate before export, and check again after set_precision, which snaps coordinates to a grid and can create self-intersections in narrow features.
Why attribute trimming is worth more than it looks
GeoJSON writes every property key as text on every feature. A layer with twenty attributes and 58,879 features writes those key names 1.2 million times.
Gzip compresses repeated keys well, so the raw saving is larger than the compressed one — but the parse cost in the browser is proportional to the raw size, and that is what blocks the main thread.
Send what the style uses, plus an identifier for fetching detail on demand.
Projection, rounding and GeoJSON's CRS
- Project before measuring anything. Degrees are anisotropic.
- Select before simplifying; simplifying what you will drop is wasted.
- Round last, after simplification.
- GeoJSON must be EPSG:4326. Clients assume it and do not check.
set_precisioncan invalidate geometry. Validate afterwards.- Drop null and empty geometries; many clients throw on them.
- Six decimals is 11 cm — beyond any web map's display.
- Above ten thousand features, use tiles rather than a single file.
Internal links
- My GeoJSON is too big for the browser — the measured size levers
- Generalisation for zoom levels explained — the selection step
- How to build vector tiles from a GeoDataFrame in Python — the next step up
- How to export GeoJSON in Python with GeoPandas — the export itself
- How to fix invalid geometries in Python (GeoPandas) — the validity step
- How to simplify geometry in Python with GeoPandas and Shapely — tolerances and topology
- My web map is blank or the layer never appears — what a wrong CRS produces
- Coordinate precision and floating point in GIS explained — what rounding costs
FAQ
In what order should I do the steps?
Project, validate, select by visibility, simplify, reproject to 4326, trim attributes, round coordinates. Each step depends on the previous.
How much precision do I need?
Six decimal places, about 11 cm. Going further is not free: at four decimals, 32,384 of Leiden's 58,879 buildings collapsed to nothing.
Why does my layer not render in the browser?
Common causes are a projected CRS, invalid geometry and null geometries — all of which desktop GIS tolerates and browsers do not.
Should I simplify or drop features?
Both, in that order of importance: drop first. On Leiden's building footprints at zoom 10, selection kept one feature of 58,879, while simplification left most of the vertices in place (Example 1).
How many features can a browser handle?
Roughly ten thousand as a single GeoJSON. Beyond that the parse and render cost blocks the main thread regardless of file size.
Do I need a different file per zoom?
It helps for a handful of levels. Beyond that, vector tiles are the structural answer, because the client loads only the current view.