Building a zoning vector tile pyramid with tippecanoe

The first tile build works and the map looks fine. Then somebody measures a parcel on it, gets a figure 4 metres different from the API, and asks which one is right — because tippecanoe’s default simplification moved the boundary by more than the tolerance the change detector calls a real change. The tiles and the change feed now disagree about what shape a parcel is. This guide builds the pyramid so that does not happen: simplification bounded by the noise floor, stable ids, deliberate small-feature handling, and a manifest that says what the tiles are. It is the generation step for vector tile publishing for zoning maps.

Diagnosis: what the defaults will do to you jump to heading

Four tippecanoe defaults are wrong for parcel data specifically, and each one is silent.

Simplification exceeds your noise floor. The default --simplification=1 at low zoom moves vertices by more than the 2 m² symmetric-difference threshold that change detection & geometry diffing treats as a genuine change. The tiles then describe a shape the pipeline would call different.

Four tippecanoe defaults that are wrong for parcel data A four-row by three-column matrix of tippecanoe defaults. Rows are the default simplification, dropping features to meet a size budget, per-tile feature ids, and attribute type coercion. Columns are what the default does, the consequence for a parcel layer, and the flag that fixes it. Every consequence is silent: simplification exceeding the noise floor, small downtown parcels dropped, ids changing on every rebuild, and leading zeros stripped from parcel identifiers. Four tippecanoe defaults that are wrong for parcel data the default does consequence for parcels flag that fixes it Simplification simplification=1 exceeds the noise floor per-zoom tolerance Feature and tile size limits drops densest features small downtown parcels vanish --no-feature-limit Feature ids assigned per tile ids change every rebuild --use-attribute-for-id Attribute types coerces numeric strings 0714221 loses its zero keep the string attribute fixed the default silent consequence
All four are silent, which is why they survive a first build that "looks fine". The id row is the one that breaks the most downstream behaviour — click-through, highlighting, permalinks and the attribute join all depend on it — and the fix is a single flag.

Small features are dropped. --drop-densest-as-needed and the default --minimum-detail remove small parcels to fit the tile size budget, which for a downtown block is exactly the parcels people are looking for.

Feature ids are not preserved. Without --use-attribute-for-id, the id field is assigned per tile, so a click-through cannot correlate a rendered feature with a record and the id changes on every rebuild.

Attributes are silently coerced. Numeric-looking parcel identifiers like 0714221 become numbers, losing the leading zero, so the tile’s id no longer matches the registry’s.

# Measure the simplification error before choosing a value.
tippecanoe -z16 -Z10 -o /tmp/probe.pmtiles --simplification=1 parcels.geojson
python3 - <<'PY'
# Decode one tile and compare against the source geometry, per zoom.
from mvt_probe import max_symmetric_difference
for z in range(10, 17):
    err = max_symmetric_difference("/tmp/probe.pmtiles", "parcels.geojson", zoom=z)
    print(f"z{z}: worst-case symmetric difference {err:.2f} m²")
PY

Any zoom whose worst-case symmetric difference exceeds the change-detection floor is a zoom where the map and the API disagree.

Step-by-step implementation jump to heading

1. Pick a tolerance per zoom, bounded by the noise floor jump to heading

NOISE_FLOOR_M2 = 2.0          # from the change detector; the tiles must stay under it

# Tolerance in metres, per zoom. Chosen so the resulting area error stays below the
# floor for a typical urban parcel perimeter (~120 m): error ≈ perimeter × tolerance.
TOLERANCE_M = {
    10: 8.0,    # regional: parcels are dissolved into districts anyway
    11: 4.0,
    12: 2.0,
    13: 1.0,
    14: 0.4,
    15: 0.15,
    16: 0.0,    # full geometry: this is the zoom people measure at
}


def tolerance_is_safe(zoom, typical_perimeter_m=120.0) -> bool:
    return TOLERANCE_M[zoom] * typical_perimeter_m <= NOISE_FLOOR_M2 or zoom <= 12

Zooms 10–12 exceed the floor deliberately, because at those scales individual parcels are not rendered at all — the layer is dissolved districts. The rule is that every zoom where individual parcels appear must stay under the floor, and z16 carries full geometry so measurement matches the API exactly.

Simplification error per zoom against the change-detection floor Line chart of simplification error in square metres against zoom level from 10 to 16, for a typical 120 metre parcel perimeter. Error falls from 960 square metres at zoom 10 to 480 at 11, 240 at 12, 120 at 13, 48 at 14, 18 at 15 and zero at 16. A flat line marks the 2 square metre change-detection floor. Zooms 10 to 12 exceed the floor deliberately because individual parcels are not rendered there; every zoom that renders parcels must sit on or below the line. Simplification error per zoom against the change-detection floor 0 250 500 750 1000 1250 z10 z11 z12 z13 z14 z15 z16 Zoom level Area error (m²) at a 120 m perimeter simplification error (m²) change-detection floor
Error is roughly perimeter × tolerance, so the tolerance has to fall steeply as zoom increases. Zooms 10–12 sit above the floor deliberately — parcels are dissolved into districts there — and z16 carries full geometry so a measurement on the map matches the API exactly.

2. Generate per zoom rather than in one pass jump to heading

#!/usr/bin/env bash
set -euo pipefail

SRC=parcels.geojson          # EPSG:4326, one feature per parcel, id as a STRING
OUT=tiles
VERSION="$(date -u +%Y%m%dT%H%M%SZ)"

# Districts only, dissolved, for the regional zooms.
tippecanoe \
  --output="${OUT}/districts-${VERSION}.pmtiles" \
  --layer=districts \
  --minimum-zoom=8 --maximum-zoom=12 \
  --coalesce --reorder --detect-shared-borders \
  --simplification=4 \
  --no-tile-size-limit \
  districts.geojson

# Parcels, full detail at the top zoom, tapering below it.
tippecanoe \
  --output="${OUT}/parcels-${VERSION}.pmtiles" \
  --layer=parcels \
  --minimum-zoom=13 --maximum-zoom=16 \
  --use-attribute-for-id=feature_id \
  --preserve-input-order \
  --detect-shared-borders \
  --simplification=1 \
  --no-simplification-of-shared-nodes \
  --no-feature-limit --no-tile-size-limit \
  --no-tiny-polygon-reduction \
  "${SRC}"

The four flags that matter: --use-attribute-for-id keeps the id stable, --no-tiny-polygon-reduction stops small parcels being merged into their neighbours, --no-feature-limit --no-tile-size-limit stops parcels being dropped to meet a byte budget, and --detect-shared-borders with --no-simplification-of-shared-nodes keeps adjacent parcels from developing gaps when simplified independently — the visual artefact that makes a simplified parcel map look broken.

3. Make the feature id numeric, stable, and collision-checked jump to heading

import hashlib


def feature_id(parcel_id: str) -> int:
    """The MVT spec's id is an unsigned integer while parcel ids are strings, so a
    hash is required. 53 bits keeps it safe in JavaScript, and collisions must be
    checked across the WHOLE layer — a collision silently merges two parcels."""
    digest = hashlib.blake2b(parcel_id.encode(), digest_size=8).digest()
    return int.from_bytes(digest, "big") >> 11          # 53 bits


def prepare_source(parcels):
    """Emit GeoJSON with a numeric id AND the original string kept as an attribute."""
    seen = {}
    for p in parcels:
        fid = feature_id(p.parcel_id)
        if fid in seen and seen[fid] != p.parcel_id:
            raise ValueError(f"feature id collision: {p.parcel_id} and {seen[fid]} "
                             f"both hash to {fid}")
        seen[fid] = p.parcel_id
        yield {
            "type": "Feature",
            "id": fid,
            "properties": {
                "feature_id": fid,
                "parcel_id": p.parcel_id,        # the string, unmangled
                "zoning_class": p.canonical_class,
                "overlays": ",".join(sorted(p.overlay_codes)),
            },
            "geometry": p.geometry_geojson,
        }

4. Write the manifest, and make the path carry the version jump to heading

import json


def write_manifest(out_dir, version, source_digest, effective_date, taxonomy_version):
    """Without this a screenshot of the map is an undated assertion."""
    manifest = {
        "pyramid_version": version,
        "generated_at_utc": version,             # the version IS the timestamp
        "source_snapshot_digest": source_digest,
        "as_of": effective_date.isoformat(),     # the zoning effective date, not the build time
        "taxonomy_version": taxonomy_version,    # what the style file must match
        "tolerance_m_by_zoom": TOLERANCE_M,
        "noise_floor_m2": NOISE_FLOOR_M2,
        "layers": {"districts": [8, 12], "parcels": [13, 16]},
    }
    (out_dir / f"manifest-{version}.json").write_text(json.dumps(manifest, indent=2))
    return manifest

Versioning the path — /tiles/parcels-20260811T0230Z.pmtiles — means a geometry rebuild never needs a CDN purge: the new URL is simply not in any cache. That trades a cold cache for eliminating the entire class of “the purge did not take” incidents.

Verification & testing jump to heading

def test_simplification_stays_under_the_noise_floor():
    for zoom in range(13, 17):                   # zooms where parcels are rendered
        err = max_symmetric_difference(TILES, SOURCE, zoom=zoom)
        assert err <= NOISE_FLOOR_M2, (
            f"z{zoom} simplification error {err:.2f} m² exceeds the {NOISE_FLOOR_M2} m² "
            f"change-detection floor — the map and the API disagree about geometry")


def test_no_parcel_is_dropped():
    for zoom in range(13, 17):
        assert count_features(TILES, "parcels", zoom) == SOURCE_FEATURE_COUNT


def test_feature_ids_are_stable_across_rebuilds():
    a = feature_ids(build(SOURCE, version="v1"))
    b = feature_ids(build(SOURCE, version="v2"))
    assert a == b


def test_parcel_id_keeps_its_leading_zero():
    props = feature_properties(TILES, feature_id("0714221"))
    assert props["parcel_id"] == "0714221"       # not 714221


def test_shared_borders_have_no_gaps():
    """Two adjacent parcels simplified independently drift apart. This is the visual
    artefact that makes a simplified parcel map look broken."""
    for zoom in (13, 14, 15):
        assert max_gap_between_neighbours(TILES, zoom) < 0.05     # 5 cm


def test_manifest_is_present_and_dates_the_data():
    m = read_manifest(TILES)
    assert m["as_of"] and m["source_snapshot_digest"] and m["taxonomy_version"]

Failure recovery jump to heading

Tiles and API disagree on geometry. Compare the simplification error per zoom against the noise floor. Rebuild the offending zooms with a tighter tolerance — and if a zoom cannot meet the floor at an acceptable tile size, remove parcels from that zoom rather than shipping a shape the pipeline would call different.

How long each pyramid failure takes to notice Lollipop chart of how long each pyramid failure typically goes unnoticed. Tiles and the API disagreeing on geometry is noticed after about 40 days, when somebody measures. Parcels missing from a dense tile is noticed after about 9 days. Ids changing after a rebuild is noticed within about 1 day, because click-through breaks. A leading zero stripped from a parcel identifier goes unnoticed for about 120 days, since it only surfaces when a join fails. A dashed rule marks a week. How long each pyramid failure takes to notice 0 50 100 150 a week — beyond this it is in published output ids change on rebuild 1 days parcels missing from a dense tile 9 days tiles and API disagree on geometry 40 days leading zero stripped 120 days Days until somebody notices
The ranking is the argument for the tests rather than for vigilance: the failures that break visibly are found in a day, and the ones that corrupt data quietly — a geometry disagreement, a stripped leading zero — run for months. Both of the slow ones are one assertion each.

Parcels missing from a dense downtown tile. A size or feature limit dropped them. Rebuild with the limits disabled and check the resulting tile size; if a tile is genuinely too large, split the layer rather than dropping features, because the dropped ones are the ones users zoom in to find.

Ids changed after a rebuild. The build did not use --use-attribute-for-id, so every client-side selection, highlight and permalink is broken. Rebuild with the flag, and add the stability test — this is the failure most likely to recur, because the flag is easy to omit and nothing about the output looks wrong.

Frequently asked questions jump to heading

Why tie the simplification tolerance to the change-detection floor?

Because otherwise the map and the API describe different shapes, and a user who measures on the map gets a different answer from a query. Keeping every parcel-rendering zoom under the floor makes the two consistent by construction — any difference the tiles introduce is smaller than the difference the pipeline is willing to call a change.

Is it safe to disable the tile size limit?

For parcel layers, yes, and it is usually necessary — the alternative is dropping features, and the dropped ones are the small downtown parcels users are looking for. Watch the resulting tile sizes: if a tile exceeds a megabyte, split the layer by attribute or reduce the zoom range rather than letting features disappear.

Why version the tile path instead of purging the CDN?

Because purge APIs are eventually consistent and occasionally silent, and an unconfirmed purge is how a correct pipeline serves a wrong map. A versioned path is never in any cache, so a geometry rebuild needs no purge at all. The cost is a cold cache after each rebuild, which for a monthly geometry rebuild is a good trade.

What breaks if the feature id is not stable?

Anything that refers to a rendered feature: click-through to a record, highlighting a selection, a permalink to a parcel, and the client-side attribute join. All of them break silently — the map still renders — which is why the id-stability test is worth having even though the flag is one word.