Generalisation for Zoom Levels Explained
On this page
Problem statement
The instinct for making a map work at low zoom is to simplify the geometry. Measured on Leiden's 58,879 OpenStreetMap building footprints, that instinct is almost entirely wrong.
Simplifying every building to a one-pixel tolerance at each zoom (Example 1):
zoom tolerance vertices kept
10 93.79 m 54.5%
12 23.45 m 54.6%
14 5.86 m 56.9%
16 1.47 m 74.1%
Even at zoom 10's tolerance of 93.79 m, simplification keeps 54.5% of the vertices. It cannot do better, because a building footprint averages 7.4 vertices, closing point included, and a valid polygon needs at least four.
Dropping features smaller than two by two pixels instead:
zoom drop below features kept
10 35,183 mΒ² 1 of 58,879 (0.002%)
12 2,199 mΒ² 211 of 58,879 (0.358%)
14 137 mΒ² 3,603 of 58,879 (6.119%)
16 9 mΒ² 43,752 of 58,879 (74.308%)
At zoom 10, simplification keeps 54.5% of the vertices and selection keeps 0.002% of the features β one building.
Quick answer
Generalise by selection first, simplification second:
import math
def generalise(gdf_utm, zoom, lat, min_visible_px=2.0):
"""Drop what cannot be seen, then simplify what remains."""
m_per_px = 156543.03392 * math.cos(math.radians(lat)) / (2 ** zoom)
min_area = (min_visible_px * m_per_px) ** 2
visible = gdf_utm[gdf_utm.geometry.area >= min_area].copy()
visible["geometry"] = visible.geometry.simplify(m_per_px,
preserve_topology=True)
print(f" z{zoom}: {len(visible):,} of {len(gdf_utm):,} features "
f"({len(visible) / len(gdf_utm):.1%}) at {m_per_px:.1f} m/px")
return visible
import geopandas as gpd
buildings = gpd.read_parquet("buildings.parquet")
LAT = float(buildings.geometry.centroid.y.mean())
utm = buildings.to_crs(buildings.estimate_utm_crs())
for zoom in (10, 14):
generalise(utm, zoom, LAT)
z10: 1 of 58,879 features (0.0%) at 93.8 m/px
z14: 3,603 of 58,879 features (6.1%) at 5.9 m/px
A feature smaller than about two screen pixels cannot be seen. Sending it costs bytes and renders nothing.
What a pixel is worth, drop the invisible, simplification's floor
1. Work out what one pixel is worth
m_per_px = 156543.03392 * cos(latitude) / 2^zoom
At Leiden's 52.2Β° north: 93.8 m at zoom 10, 23.4 m at zoom 12, 5.9 m at zoom 14, 1.5 m at zoom 16.
Everything else follows from that number. A tolerance finer than a pixel is invisible; a feature smaller than a couple of pixels is invisible.
2. Drop what cannot be seen
A two-by-two-pixel threshold at zoom 10 is 35,183 mΒ² β about three and a half hectares. One building in the dataset qualifies.
That is the correct output. A zoom-10 view of a city should show its footprint, not 58,879 individually invisible polygons, and no styling makes 58,879 sub-pixel rectangles legible.
3. Understand why simplification hits a floor
simplify(preserve_topology=True) will not collapse a polygon into something invalid, so it keeps at least four distinct vertices plus a closing point.
With 434,355 vertices across 58,879 buildings β 7.4 each β most footprints are already near that minimum. Hence the floor in the table above, which barely moves between zoom 10 and zoom 14.
Simplification is effective on features with many vertices: coastlines, administrative boundaries, rivers, contours. On small compact polygons it does nothing.
4. Replace, rather than only remove
Dropping everything leaves a hole. The standard replacements:
- Aggregate β dissolve buildings into built-up areas at low zoom.
- Substitute β draw a point where a polygon would be sub-pixel.
- Sample β keep the largest n per tile so density is suggested without every feature.
- Rank β keep by importance, not just size, where an attribute expresses it.
5. Generalise once per zoom, not once
Each zoom level needs its own generalisation, applied before tiling. Generalising once and reusing it means the wrong detail at every level but one.
preserve_topology=True refuses to destroy a polygon, so simplification bottoms out well above zero.Per-zoom generalisation with its cost
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-zoom-generalisation-explained-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 β a per-zoom generalisation with the cost reported
import math
import geopandas as gpd
def generalise_for_zoom(gdf, zoom, lat=None, min_visible_px=2.0,
simplify_px=1.0, keep_largest=None):
"""Select by visible size, then simplify to a pixel."""
utm = gdf.to_crs(gdf.estimate_utm_crs())
lat = lat if lat is not None else float(gdf.geometry.centroid.y.mean())
m_per_px = 156543.03392 * math.cos(math.radians(lat)) / (2 ** zoom)
areas = utm.geometry.area
min_area = (min_visible_px * m_per_px) ** 2
keep = areas >= min_area
subset = utm[keep].copy()
if keep_largest and len(subset) > keep_largest:
subset = subset.iloc[subset.geometry.area.argsort()[::-1][:keep_largest]]
before = int(subset.geometry.count_coordinates().sum())
subset["geometry"] = subset.geometry.simplify(simplify_px * m_per_px,
preserve_topology=True)
after = int(subset.geometry.count_coordinates().sum())
print(f" z{zoom:<3} {m_per_px:8.2f} m/px "
f"features {len(subset):6,}/{len(gdf):,} ({keep.mean():6.2%}) "
f"vertices {after:8,}/{before:,} ({after / max(before, 1):6.2%})")
return subset.to_crs(gdf.crs)
for zoom in (10, 12, 14, 16):
generalise_for_zoom(buildings, zoom)
z10 93.79 m/px features 1/58,879 ( 0.00%) vertices 517/1,600 (32.31%)
z12 23.45 m/px features 211/58,879 ( 0.36%) vertices 1,781/12,665 (14.06%)
z14 5.86 m/px features 3,603/58,879 ( 6.12%) vertices 20,984/65,436 (32.07%)
z16 1.47 m/px features 43,752/58,879 (74.31%) vertices 248,966/355,778 (69.98%)
And the two levers measured separately over all 58,879 buildings β simplification alone, then selection alone β which is where the tables at the top of the page come from:
import math
vertices = int(utm.geometry.count_coordinates().sum())
print(f"{vertices:,} vertices, {vertices / len(utm):.1f} per building\n")
print("zoom tolerance vertices kept")
for zoom in (10, 12, 14, 16):
m_per_px = 156543.03392 * math.cos(math.radians(LAT)) / 2 ** zoom
kept = utm.geometry.simplify(m_per_px, preserve_topology=True).count_coordinates().sum()
print(f" {zoom} {m_per_px:8.2f} m {kept / vertices:6.1%}")
print("\nzoom drop below features kept")
for zoom in (10, 12, 14, 16):
m_per_px = 156543.03392 * math.cos(math.radians(LAT)) / 2 ** zoom
threshold = (2 * m_per_px) ** 2
kept = int((utm.geometry.area >= threshold).sum())
print(f" {zoom} {threshold:9,.0f} mΒ² {kept:6,} of {len(utm):,} ({kept / len(utm):.3%})")
434,355 vertices, 7.4 per building
zoom tolerance vertices kept
10 93.79 m 54.5%
12 23.45 m 54.6%
14 5.86 m 56.9%
16 1.47 m 74.1%
zoom drop below features kept
10 35,183 mΒ² 1 of 58,879 (0.002%)
12 2,199 mΒ² 211 of 58,879 (0.358%)
14 137 mΒ² 3,603 of 58,879 (6.119%)
16 9 mΒ² 43,752 of 58,879 (74.308%)
Reporting both reductions side by side is what makes the point. At low zoom the feature column collapses and the vertex column barely moves.
Example 2 β aggregation as a replacement for the dropped features
import geopandas as gpd
def aggregate_for_low_zoom(gdf, cell_m=500, min_features=3):
"""Dissolve dense clusters into built-up polygons."""
utm = gdf.to_crs(gdf.estimate_utm_crs())
centroids = utm.geometry.representative_point()
utm["cell"] = (
(centroids.x // cell_m).astype(int).astype(str) + "_" +
(centroids.y // cell_m).astype(int).astype(str))
counts = utm.groupby("cell").size()
dense = counts[counts >= min_features].index
subset = utm[utm["cell"].isin(dense)]
dissolved = (subset.assign(geometry=subset.geometry.buffer(cell_m / 10))
.dissolve(by="cell")
.reset_index())
dissolved["geometry"] = dissolved.geometry.buffer(-cell_m / 10)
dissolved["n_features"] = counts.loc[dissolved["cell"]].values
print(f" {len(gdf):,} features -> {len(dissolved):,} aggregates "
f"({len(dissolved) / len(gdf):.2%})")
return dissolved.to_crs(gdf.crs)
aggregates = aggregate_for_low_zoom(buildings)
print(f" largest aggregate holds {aggregates['n_features'].max():,} buildings")
58,879 features -> 109 aggregates (0.19%)
largest aggregate holds 1,913 buildings
Buffering out, dissolving and buffering back in is the standard way to merge features that are close but not touching. The buffer distance sets what counts as "close", and it is the parameter to expose.
Example 3 β keeping the most important features per tile
import mercantile
import numpy as np
def top_per_tile(gdf, zoom, max_per_tile=200, importance=None):
"""Cap the feature count per tile, keeping the most important."""
utm = gdf.to_crs(gdf.estimate_utm_crs())
score = (gdf[importance] if importance and importance in gdf
else utm.geometry.area)
centroids = gdf.geometry.representative_point()
tiles = [mercantile.tile(x, y, zoom)
for x, y in zip(centroids.x, centroids.y)]
keys = np.array([f"{t.z}/{t.x}/{t.y}" for t in tiles])
keep = np.zeros(len(gdf), bool)
over = 0
for key in np.unique(keys):
idx = np.nonzero(keys == key)[0]
if len(idx) <= max_per_tile:
keep[idx] = True
continue
over += 1
best = idx[np.argsort(score.values[idx])[::-1][:max_per_tile]]
keep[best] = True
print(f" {over} of {len(np.unique(keys))} tiles were over "
f"{max_per_tile} features; kept {keep.sum():,} of {len(gdf):,}")
return gdf[keep]
kept = top_per_tile(buildings, zoom=14, max_per_tile=200)
16 of 18 tiles were over 200 features; kept 3,385 of 58,879
Capping per tile rather than globally keeps the map balanced: a dense city centre and a sparse suburb both get up to the cap, so neither is empty and neither is unreadable.
Why selection beats simplification
Why simplification hits a floor
Douglas-Peucker removes vertices whose deviation from the simplified line is below the tolerance. preserve_topology=True additionally refuses any removal that would make the geometry invalid or self-intersecting.
A polygon needs at least three distinct vertices plus a closing point. The measured dataset averages 7.4 vertices per building, so most are only a few vertices above the floor.
At zoom 10's 93.79 m tolerance the algorithm removes everything it is allowed to and stops at 54.5% β barely less than the 56.9% it keeps at zoom 14's 5.86 m. Increasing the tolerance further changes little, because the constraint is validity rather than tolerance.
Why selection is so much more effective
Selection has no floor. If a feature is below the visibility threshold, all of it goes β every vertex, every attribute, every byte.
At zoom 10 the threshold is 35,183 mΒ² and one building exceeds it. That is a reduction of more than four orders of magnitude, against simplification's factor of 1.8.
The two are not alternatives; they are for different zoom ranges. At high zoom everything is visible and simplification trims the vertices. At low zoom almost nothing is visible and selection removes the features.
Why sub-pixel features are worse than useless
A polygon smaller than a pixel renders as at most one pixel, indistinguishable from a point and often anti-aliased into invisibility.
It still costs bytes in the tile, time in the encoder, and time in the browser's rendering loop. At zoom 10 the dataset is 58,879 features producing at most a few hundred distinguishable pixels.
The result is slow tiles that look like noise. Removing them makes the map both faster and more legible.
Why generalisation must be per zoom
Each zoom level has its own pixel size, so its own visibility threshold and its own useful tolerance β 93.8 m at zoom 10 and 1.5 m at zoom 16 in Leiden, a factor of 64.
Generalising once and reusing the result means either over-detailed low zooms or over-simplified high ones.
The cost is real: a pyramid of eight zoom levels needs eight generalisation passes over the source. That is a batch job, and it is what tile generation actually consists of.
Selection order and two-pixel rules
- Selection before simplification. At low zoom, selection removes nearly everything and simplification removes under half.
preserve_topology=Truefloors the vertex reduction at whatever a valid geometry needs.- Two screen pixels is a reasonable visibility threshold.
- Simplification suits long features β coastlines, boundaries, rivers β not small footprints.
- Replace what you drop: aggregate, substitute a point, or sample per tile.
- Cap per tile, not globally, so dense and sparse areas are both readable.
- Simplify in a projected CRS; a tolerance in degrees is not a distance.
- Generalise once per zoom level.
Internal links
- Web map tiles explained: XYZ, zoom levels and the tile pyramid β where the pixel size comes from
- Vector tiles explained: MVT, layers and why they are not GeoJSON β what the generalised data becomes
- How to build vector tiles from a GeoDataFrame in Python β applying this per zoom
- How to simplify geometry in Python with GeoPandas and Shapely β the simplification itself
- My GeoJSON is too big for the browser β the problem this solves
- How to prepare a GeoDataFrame for the web β the other size levers
- How to dissolve polygons in Python (GeoPandas) β the aggregation step
- Topological Coverage: What "Clean" Means for Adjacent Polygons
FAQ
How do I generalise data for low zoom levels?
Drop features below about two screen pixels first, then simplify what remains to about one pixel. Selection does far more work than simplification.
Why does simplification barely reduce my data?
Because preserve_topology=True will not collapse a polygon below validity. On Leiden's building footprints, averaging 7.4 vertices, simplification kept 54.5% even at zoom 10's 93.79 m tolerance.
How small is too small to draw?
Under about two by two screen pixels. At zoom 10 and Leiden's 52.2Β° north that is 35,183 mΒ² β about three and a half hectares.
What should replace the features I drop?
Aggregate them into built-up areas, substitute points, or keep the largest few per tile. Dropping without replacing leaves a hole.
Should I generalise once or per zoom?
Per zoom. The pixel size varies by a factor of 64 between zoom 10 and 16, so one generalisation is right for at most one level.
Does this apply to lines as well as polygons?
Simplification works much better on lines with many vertices β coastlines, rivers, boundaries. Selection still matters for short lines at low zoom.
What tolerance should I simplify to?
About one screen pixel at the target zoom: 156543.03392 * cos(latitude) / 2^zoom. Finer than that is invisible.