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.
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.
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.
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.
Related jump to heading
- Parent topic: Vector Tile Publishing for Zoning Maps
- Section overview: Spatial Impact Analysis & Zoning Change Detection
- Change Detection & Geometry Diffing — the noise floor the tolerance must respect
- Invalidating tile caches when a rezone lands — what happens after the pyramid exists
- Geospatial Format Conversion — preparing the GeoJSON the build consumes