How to Prepare a GeoDataFrame for the Web
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 59,391 real building polygons:
GeoParquet, as analysed 5.06 MB
GeoJSON, as written 27.04 MB gzipped 2.65 MB
GeoJSON, 4 decimals 19.97 MB gzipped 1.11 MB
one zoom-15 vector tile 5.80 kB gzipped 4.54 kB
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
The order is not arbitrary: project before measuring, select before simplifying, simplify before reprojecting, round last.
Step-by-step solution
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 66 km at 53Β° north and 111 km at the equator.
utm = gdf.to_crs(gdf.estimate_utm_crs())
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
if invalid.any():
gdf.loc[invalid, "geometry"] = gdf.loc[invalid, "geometry"].make_valid()
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 53.5Β° north, one pixel is 5.7 m, so a two-pixel threshold is about 129 mΒ². That keeps 21.5% of the buildings; at zoom 10 it keeps 3 of 59,391.
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)
Expect this to do very little on small polygons β 57.9% of vertices kept at zoom 10 β and a great deal on coastlines and boundaries.
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 = out[["name", "category", "geometry"]]
out["geometry"] = out.geometry.set_precision(1e-6)
Six decimal places is about 11 cm. Measured, going from seven to four decimals took a GeoJSON from 2.83 MB to 1.11 MB gzipped.
Code examples
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
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
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}")
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.
Explanation
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 66 km and north-south 111 km, so a "0.001 degree" tolerance is 1.8 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 [422200, 5886250]. A web map interprets those as longitude 422,200 and latitude 5,886,250, 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 59,391 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.
Edge cases or notes
- 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
What CRS should web data be in?
EPSG:4326 for GeoJSON. Clients assume it rather than reading it, so a projected GeoJSON is placed off the world.
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 from seven to four took a measured GeoJSON from 2.83 MB to 1.11 MB gzipped.
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 building footprints, simplification kept 57.9% of vertices while selection kept 0.005% of features at zoom 10.
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.