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.

From feature density to a defensible max zoomCount features per square kilometre, convert to features per tile at a candidate zoom, multiply by mean bytes per feature, and compare the result with the tile size budget.THE CALCULATIONDensityfeatures / km2from the source dataPer tilex tile area at zBytesx bytes per featuremeasured, not assumedCompareagainst 500 KBthe largest z thatstill fits
One inequality decides it: features per tile x bytes per feature < 500 KB. Everything else in this page is measuring the two left-hand terms honestly.

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-zoom explicitly 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).

python
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

What each additional zoom level costsStorage and build time for the same urban dataset generated to zoom 14, 15, 16 and 17, showing both roughly quadrupling per level.COST OF ONE MORE ZOOMGB stored / minutes to buildz14 max — 1.1 GB, 12 min12 minz15 max — 4.0 GB, 41 min41 minz16 max — 15 GB, 2 h 402 h 40z17 max — 58 GB, 10 h 2010 h 20
Each level is four times the tiles of the one below it. Generating to z17 when readers stop at z16 quadruples the archive to serve a zoom nobody requests.
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

The checks that separate a measured max zoom from a guessed oneFive checks: density measured on the densest region, bytes per feature measured after attribute filtering, the peak zoom identified, overzoom considered, and the client's actual maximum zoom confirmed.BEFORE YOU COMMITDensity measured on the densest tile, not the averagea citywide mean hides the one downtown tile that overflowsBytes per feature measured after attribute filteringfiltering can halve the figure the whole calculation rests onThe peak-size zoom identified, not assumed to be the maxtile size falls again above the peakOverzoom evaluated before adding a levelz14 tiles overzoomed to z16 are freeThe style's maxzoom checked against the buildgenerating past what the client requests is pure waste
The last one is the cheapest and the most often skipped. Read the style, not the build script, for the zoom readers can actually reach.

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:

bash
# 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:

bash
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:

bash
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:

bash
ogr2ogr -t_srs EPSG:4326 buildings_wgs84.geojson buildings.geojson

Parent: Zoom Level Optimization Strategies

FAQ

Should density be measured as an average?

No. Measure the densest tile, not the citywide mean. The average hides the one downtown tile that overflows, and that tile is the one that decides the maximum zoom.

Does latitude affect the calculation?

Yes, and substantially. A z14 tile at 60° north covers about a quarter of the ground area of one at the equator, so the equatorial figure over-estimates features per tile by four times.

What if the calculation says a zoom deeper than my readers use?

Then stop at what they use. Generating past the style’s declared maximum zoom produces tiles nobody ever requests, at four times the storage per level.

Does attribute filtering change the answer?

It changes the bytes-per-feature term, often by half. Measure it after filtering, or the calculation rests on a number the final build will not produce.