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.

Six ordered steps: project, validate, select by visibility, simplify, reproject to WGS 84, trim attributes and round coordinates. six steps, and the order is load-bearing1project to a metric CRSthresholds are distances2validate and drop emptiesbrowsers are unforgiving3select by visible sizeorders of magnitude4simplify to about a pixeltolerance in metres5reproject to EPSG:4326GeoJSON is 43266trim attributes, round to 6 dpabout 11 cmRounding before simplifying wastes the rounding spatialworkflow.io
Each step depends on the previous one. Rounding before simplifying wastes the rounding.

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 one-degree square at 53 degrees north measuring 66 km east-west and 111 km north-south, so a tolerance in degrees is anisotropic. one degree at 53.5° northone degree0.001 degreeeast–west!66.3 km66 mnorth–south!111.2 km111 mA 0.001-degree tolerance is 1.7 times coarser north–south than east–west spatialworkflow.io
A tolerance in degrees is a different distance in each direction. Project before measuring anything.

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.

Detecting a projected GeoJSON from its bounds rather than from its declared CRS. the bounds test catches what the metadata missesbounds look likewhat it is4.44 … 4.53, 52.13 … 52.18longitude and latitude — correct598,533 … 604,679, 5,776,182 …UTM metres — off the world aslon/lat494,297 … 504,271, 6,823,131 …Web Mercator metresClients assume EPSG:4326 rather than reading it, so a wrong CRS is silent spatialworkflow.io
Checking the bounds catches a projected GeoJSON even when the CRS metadata is missing.

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_precision can 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.

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.