Calculating Optimal Max Zoom for Urban Datasets
For dense urban layers, the production-safe maximum zoom is floor(log2(40075016.686 / (tile_px × min_feature_m))) − penalty, where the penalty is a sum of empirical deductions (0–4) derived from vertex density, feature clustering, label collision risk, and layer overdraw — typically resolving to z16–z18 for building footprints and road networks.
When to Use This Approach
Apply this formula-plus-penalty method when all three of the following are true:
- The source dataset covers a dense urban area (>500 features/km²) — municipal building footprints, street centerlines, cadastral parcels, or POI layers.
- You are generating tiles with tippecanoe and need to specify
--maximum-zoomexplicitly rather than relying on--maximum-zoom=g(automatic). - Client-side rendering targets WebGL via MapLibre GL and must stay within a 500 KB per-tile payload budget and a 60 fps decode threshold.
Use the simpler --maximum-zoom=g (tippecanoe’s auto mode) when datasets are sparse (<100 features/km²) or cover regional/national extents — the penalty framework is unnecessary overhead for low-density inputs.
Do not apply this formula to raster or terrain layers. It is specific to polygon, line, and point geometry encoded in the Mapbox Vector Tile Specification v2.1, where coordinates are quantized to a 4096×4096 integer grid per tile.
Specification Detail
The formula derives from the Web Mercator ground resolution equation defined by the MVT specification’s coordinate grid.
| Parameter | Description | Typical urban value |
|---|---|---|
C |
WGS84 equatorial circumference (m) | 40075016.686 (fixed) |
tile_size_px |
Tile pixel dimension | 512 (modern) or 256 (legacy) |
min_feature_size_m |
Smallest ground feature that must remain legible | 0.5–2.0 m for building footprints |
z_base |
Theoretical maximum zoom from ground resolution alone | 17–20 for urban inputs |
| Penalty: vertex density | +1 if avg vertices/feature > 80 | Common for parcel polygons |
| Penalty: feature clustering | +1 if est. features/tile > 800 | Dense POI layers, road graphs |
| Penalty: label collision | +1 if POI/label density > 150/tile | City-centre retail or transit data |
| Penalty: layer overdraw | +1 if >4 layers render simultaneously at target zoom | Multi-source composite maps |
| Hard cap | MVT spec coordinate grid precision limit | z19 max |
Ground resolution per tile at key zoom levels (512 px tile, Web Mercator):
| Zoom | m/px (equator) | Typical use |
|---|---|---|
| z14 | 9.55 m | District-level roads |
| z15 | 4.78 m | Street grid, major parcels |
| z16 | 2.39 m | Building footprints |
| z17 | 1.19 m | Individual facades, alley widths |
| z18 | 0.60 m | Sub-meter survey features |
| z19 | 0.30 m | Coordinate grid precision limit |
Production Command
The Python function below profiles a dataset and emits both the max_zoom integer and the ready-to-run tippecanoe command. Feed it values from a geopandas profiling pass (see the zoom level optimization strategies cluster for the profile_density() helper).
import math
import subprocess
from pathlib import Path
def calculate_optimal_max_zoom(
min_feature_size_m: float,
avg_vertices_per_feature: float,
features_per_tile_estimate: int,
poi_density_per_tile: int = 0,
overlapping_layers: int = 1,
tile_size_px: int = 512,
earth_circumference_m: float = 40075016.686,
) -> int:
"""
Calculate production-safe max zoom for urban vector datasets.
Args:
min_feature_size_m: Smallest ground dimension that must remain legible.
avg_vertices_per_feature: Mean vertex count across all geometries.
features_per_tile_estimate: Expected feature count at target zoom tile.
poi_density_per_tile: Expected POI / label count per tile.
overlapping_layers: Concurrent layers rendered at this zoom.
tile_size_px: Tile dimension in pixels (256 or 512).
earth_circumference_m: WGS84 equatorial circumference.
Returns:
Production-safe max zoom level (int), hard-capped at 19.
"""
if min_feature_size_m <= 0:
raise ValueError("min_feature_size_m must be > 0")
if tile_size_px not in (256, 512):
raise ValueError("tile_size_px must be 256 or 512")
z_base = math.log2(earth_circumference_m / (tile_size_px * min_feature_size_m))
penalty = 0
if avg_vertices_per_feature > 80:
penalty += 1
if features_per_tile_estimate > 800:
penalty += 1
if poi_density_per_tile > 150:
penalty += 1
if overlapping_layers > 4:
penalty += 1
return min(int(math.floor(z_base - penalty)), 19)
def run_tippecanoe(
input_geojson: Path,
output_pmtiles: Path,
layer_name: str,
min_zoom: int,
max_zoom: int,
) -> None:
"""Run tippecanoe with the computed zoom range."""
cmd = [
"tippecanoe",
f"--minimum-zoom={min_zoom}",
f"--maximum-zoom={max_zoom}",
"--drop-densest-as-needed", # enforces 500 KB tile budget
"--extend-zooms-if-still-dropping",
"--simplification=0.5",
f"--layer={layer_name}",
f"--output={output_pmtiles}",
"--force",
str(input_geojson),
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
raise RuntimeError(f"tippecanoe failed:\n{result.stderr}")
# --- Example: downtown building footprints ---
# Profile values from geopandas profile_density() run on buildings.geojson:
# avg_vertices_per_feature = 92 (complex polygon outlines)
# features_per_tile_estimate = 1100
# poi_density_per_tile = 180 (embedded address labels)
# overlapping_layers = 5 (buildings + roads + transit + parcels + labels)
z_max = calculate_optimal_max_zoom(
min_feature_size_m=0.8,
avg_vertices_per_feature=92,
features_per_tile_estimate=1100,
poi_density_per_tile=180,
overlapping_layers=5,
tile_size_px=512,
)
# z_base ≈ 19.6 → penalty = 4 → z_max = 15, capped below hard limit → 15
print(f"Computed max zoom: {z_max}") # → 15
run_tippecanoe(
input_geojson=Path("buildings.geojson"),
output_pmtiles=Path("buildings.pmtiles"),
layer_name="buildings",
min_zoom=12,
max_zoom=z_max,
)
The output .pmtiles file is immediately usable with a PMTiles-compatible tile server or served directly from object storage via HTTP range requests.
Interaction Effects
This max-zoom calculation interacts with three other tippecanoe controls. Getting them out of sync causes silent data loss or payload overruns.
--drop-densest-as-needed (always pair with this)
When the computed max_zoom is too low for some features, tippecanoe drops features rather than exceeding the tile budget. This is the correct behaviour — dropping is preferable to a 2 MB tile. If you observe features disappearing at max_zoom, lower min_feature_size_m by 20% and re-run the formula. Do not remove --drop-densest-as-needed to “fix” missing features; that will produce oversized tiles. See essential tippecanoe flags for production builds for the full flag interaction matrix.
--simplification and geometry vertex count
The penalty deduction for avg_vertices_per_feature > 80 assumes you have also set --simplification to at least 0.5. If you use --no-simplification, the vertex penalty should increase to -2 because no geometry reduction occurs during generation. The Visvalingam vs Douglas-Peucker comparison explains how each algorithm changes the effective vertex count at a given zoom, which feeds directly back into the penalty evaluation.
--drop-fraction-as-needed vs --drop-densest-as-needed
--drop-fraction-as-needed removes a random fraction of features; --drop-densest-as-needed removes the spatially densest cluster first. For urban datasets, always use the latter — random dropping produces visually incoherent results at high feature densities. If you switch to fraction-based dropping, increase the computed max_zoom by 1 to compensate for the reduced per-tile pressure.
Performance Impact
| Scenario | Tile size at computed zoom | Build time (100k features) |
|---|---|---|
| No penalty (sparse suburban) | 80–150 KB | ~40 s |
| 1 penalty (moderate density) | 150–280 KB | ~55 s |
| 2 penalties (dense urban) | 250–400 KB | ~75 s |
| 3–4 penalties (financial district) | 350–500 KB | ~90 s |
Halving tile_size_px from 512 to 256 raises z_base by 1.0 (one full zoom level) because the formula’s denominator halves. This makes 256 px tiles appear to permit a higher zoom, but each tile covers one quarter the ground area — you end up generating four times as many tiles. Stick with 512 px tiles for new pipelines.
Storing the output in MBTiles (SQLite) versus PMTiles has no effect on the max zoom calculation itself, but does affect CDN delivery: PMTiles supports HTTP range requests for individual tile retrieval, which is critical for high-zoom urban tilesets where a single city-sized .pmtiles file may contain 500k+ tiles.
Common Mistakes
Mistake 1: Using --maximum-zoom=g on urban data and exceeding 500 KB tiles
Symptom: tippecanoe’s automatic zoom selection (-zg) sets max_zoom=18 or higher for building footprint data, producing tiles of 1–3 MB. CDN caching becomes unreliable and mobile clients crash.
Diagnosis:
# Check tile size distribution after generation
pmtiles show buildings.pmtiles | grep -E "tile_count|max_zoom|avg_tile"
Fix: replace -zg with the explicit --maximum-zoom=<computed_value> from this formula.
Mistake 2: Applying the penalty to the wrong tile pixel size
Symptom: z_base is 1 level too low, causing the pipeline to cap at z14 when z15 is safe. This happens when the dataset was profiled assuming 512 px tiles but --maximum-zoom was set based on a 256 px calculation.
Diagnosis: confirm the tile size used in generation matches the one passed to calculate_optimal_max_zoom:
tippecanoe --maximum-zoom=16 --output=out.pmtiles --force input.geojson 2>&1 | grep "tile size"
Fix: pass the same tile_size_px value (256 or 512) to both the formula and to tippecanoe. Tippecanoe’s default is 256 px internally; if you’re comparing with MapLibre’s tileSize: 512 setting, note that MapLibre upscales 256 px tiles — it does not change tile generation.
Mistake 3: Ignoring coordinate projection of the input
Symptom: z_base calculates correctly but tiles at max_zoom show snapping artifacts or mis-aligned features, often visible as gaps between adjacent polygons.
Diagnosis: the formula assumes the input geometry is in EPSG:4326 (degrees) and tippecanoe internally projects to EPSG:3857. If the source file is already in a projected CRS (e.g., EPSG:32632 UTM), tippecanoe may not re-project correctly:
ogrinfo -al -so buildings.geojson | grep "PROJ"
# Should show GEOGCS["WGS 84"] — if it shows a UTM or local CRS, re-project first
Fix: re-project to EPSG:4326 with ogr2ogr before running tippecanoe:
ogr2ogr -t_srs EPSG:4326 buildings_wgs84.geojson buildings.geojson
Parent: Zoom Level Optimization Strategies
Related
- Essential tippecanoe flags for production builds — complete flag reference covering
--drop-densest-as-needed,--extend-zooms-if-still-dropping, and--simplificationinteractions. - Visvalingam vs Douglas-Peucker in tile generation — how algorithm choice changes effective vertex count, which feeds directly into the vertex penalty calculation.
- Dropping unused attributes to reduce tile size — attribute filtering that reduces per-tile payload independently of the zoom penalty, enabling a higher
max_zoomwithout exceeding the 500 KB budget.