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.

Six ordered steps: project, validate, select by visibility, simplify, reproject to WGS 84, trim attributes and round coordinates.
Each step depends on the previous one. Rounding before simplifying wastes the rounding.

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.

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.
A tolerance in degrees is a different distance in each direction. Project before measuring anything.

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.

Three bounding boxes identified as longitude/latitude, projected metres and Web Mercator metres.
Checking the bounds catches a projected GeoJSON even when the CRS metadata is missing.

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_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

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.