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.

Urban max zoom derivation pipeline Flowchart showing four stages: (1) Ground resolution formula produces z_base, (2) Dataset profiling measures vertex density and feature count, (3) Penalty deductions subtract 0-4 levels, (4) Hard cap at z19 yields the production max zoom. Ground resolution z_base = log2( C / (px × m)) Dataset profiling vertices/feature features/tile · POI density Penalty deduction subtract 0–4 levels z_base − penalty Production max zoom min(result, 19) → tippecanoe flag Step 1 Step 2 Step 3 Step 4

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

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:

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