Vector Tile Publishing for Zoning Maps

The pipeline is correct, the history is bitemporal, the overlays compose — and the map on the website still shows last month’s zoning, because a tile generated in June is sitting in a CDN with a one-year cache header. Publishing is where a zoning pipeline meets a caching layer built on the assumption that geometry does not change, and zoning is geometry that changes on a schedule set by planning commissions. This topic covers making tiles that stay truthful: how to shape the pyramid, how to join attributes without rebuilding polygons, and above all how to invalidate exactly what a rezone touched. It closes out the Spatial Impact Analysis & Zoning Change Detection area, consuming the change records produced by change detection & geometry diffing.

Prerequisites and operational context jump to heading

Three things need to be settled before a tile pipeline is worth building, and each one is a decision rather than a task.

A stable feature identity. Every tiled parcel needs an id that survives regeneration, because the id is what a client uses to correlate a rendered feature with the record it came from — for a click-through, a highlight, or a diff. Deriving it from a row number or a tile-local index means it changes on every rebuild and nothing downstream can rely on it.

A declared authority for attributes. Tiles carry a snapshot of attribute values, so they are a copy, and copies go stale. Which copy is authoritative has to be explicit: the tile is for rendering, the API is for answers. A client that reads a zoning code out of a tile and uses it for a compliance statement has taken a rendering optimisation and treated it as a source of truth.

A change feed to invalidate against. Without a per-parcel change record, the only available invalidation strategy is “rebuild everything,” which for a metropolitan parcel layer is hours of work for a handful of changed lots. The change records that change detection & geometry diffing already produces are exactly the input a targeted invalidation needs, which is why tiling is placed after change detection in this area rather than beside it.

Architecture: separate the geometry pyramid from the attribute join jump to heading

The decision that shapes everything else is whether attributes are baked into tiles at generation time or joined at render time. Baking is simpler and makes every attribute change a tile rebuild; joining keeps geometry stable and lets a rezone update a small data file instead.

Baked attributes against a render-time attribute join Two publishing architectures compared. In the baked design, parcel geometry and zoning attributes are combined before tiling, so the tile pyramid contains both and any zoning change requires regenerating every tile that contains the changed parcel, then purging those tiles from the CDN. In the joined design, the pyramid contains geometry and a stable feature id only, and a small state file maps feature ids to current zoning attributes. A rezone updates the state file, which is a few kilobytes with a short cache lifetime, and the geometry pyramid is untouched. The trade-off noted underneath is that the joined design needs the client to perform the join, which costs a request and some client-side work, and cannot style features the client has not loaded state for. Baked: a rezone rebuilds tiles parcel geometry 1.2 M polygons zoning attributes code · overlays tile pyramid geometry + attrs CDN long max-age One rezone → regenerate every tile containing that parcel, across every zoom level, then purge each one by URL. A single parcel appears in ~9 tiles across z12–z16. Cheap in isolation, and a county-wide re-code becomes a full rebuild of the pyramid. Joined: a rezone updates a small file parcel geometry + stable feature id tile pyramid geometry + id only zoning state file id → code, ~40 kB client join style by feature id One rezone → rewrite a 40 kB file with a 60 s max-age. The pyramid, and every CDN copy of it, is untouched. Cost: one extra request, a client-side join, and features whose state has not loaded render unstyled for a moment. Geometry changes still rebuild tiles in both designs. The join removes the frequent attribute-only rebuild — which is what a rezone is. Splits are rare; re-codes are not. Baked suits a static archive; joined suits a live map.
The split matters because of what changes and how often. A rezone is an attribute change and there are many of them; a split or merge is a geometry change and there are few. Baking attributes into the pyramid makes the frequent case expensive and the rare case no cheaper.

The joined design is not free — it costs a request, a client-side join, and a brief unstyled flash — but it changes the invalidation problem from “find and purge nine tiles per parcel across five zoom levels” to “overwrite one small file.” For a live zoning map that has to reflect an ordinance adopted this morning, that is the difference between a publishing step and a publishing project.

Zoom levels and generalisation jump to heading

Zoom What a user is doing Geometry needed Attributes needed
z8–z10 regional context district boundaries, dissolved class only
z11–z13 neighbourhood scanning simplified parcels, small lots dropped class, overlay flags
z14–z16 looking at a specific lot full parcel geometry code, overlays, capacity
z17+ measuring full geometry, no simplification everything, with citations

Simplification tolerance at each level has one hard constraint that is easy to miss: it must stay below the noise floor used for change detection. If tiles are simplified with a 3-metre tolerance while change detection treats anything above 2 m² of symmetric difference as real, then the tiles disagree with the change feed about what a parcel’s shape is, and a user who measures on the map gets a different answer from the API. Keeping the tolerance under the floor makes the two consistent by construction.

Production implementation jump to heading

The invalidation planner is the piece worth writing carefully, because it is where a targeted rebuild either works or quietly misses tiles.

import math
from dataclasses import dataclass


@dataclass(frozen=True)
class Tile:
    z: int
    x: int
    y: int

    def key(self) -> str:
        return f"{self.z}/{self.x}/{self.y}"


def tiles_for_bounds(minx, miny, maxx, maxy, zooms) -> set[Tile]:
    """Web-Mercator tile coverage for a lon/lat bounding box.

    The +1 on the ranges is deliberate: a parcel whose edge falls exactly on a
    tile boundary appears in BOTH neighbouring tiles, and an off-by-one here is
    the classic 'one stale tile on the seam' bug that survives every test
    because it only shows at one zoom, on one edge, for one parcel.
    """
    out = set()
    for z in zooms:
        n = 2 ** z
        x0 = int((minx + 180.0) / 360.0 * n)
        x1 = int((maxx + 180.0) / 360.0 * n)
        def _ytile(lat):
            r = math.radians(lat)
            return int((1.0 - math.asinh(math.tan(r)) / math.pi) / 2.0 * n)
        y0, y1 = _ytile(maxy), _ytile(miny)
        for x in range(max(0, x0), min(n - 1, x1) + 1):
            for y in range(max(0, y0), min(n - 1, y1) + 1):
                out.add(Tile(z, x, y))
    return out


GEOMETRY_CLASSES = {"boundary_adjustment", "split", "merge"}
ATTRIBUTE_CLASSES = {"rezone", "attribute_only", "overlay_only"}


def plan_invalidation(changes, zooms=range(10, 17)) -> dict:
    """Split a change set into an attribute-file rewrite and a tile rebuild.

    Attribute-only changes never touch the pyramid; geometry changes do. Handling
    them together is what turns a two-second state-file write into an hour of
    tile generation for no reason.
    """
    state_ids, rebuild = set(), set()

    for ch in changes:
        if ch.change_class in ATTRIBUTE_CLASSES:
            state_ids.add(ch.feature_id)
        elif ch.change_class in GEOMETRY_CLASSES:
            # Both the old and the new geometry must be invalidated: the parcel may
            # have moved out of a tile it used to occupy, leaving a ghost behind.
            for geom in (ch.geometry_before, ch.geometry_after):
                if geom is None:
                    continue
                rebuild |= tiles_for_bounds(*geom.bounds, zooms)
            state_ids.add(ch.feature_id)
        else:
            raise ValueError(f"unclassified change {ch.change_class!r}: refusing to "
                             f"guess whether it touches geometry")

    return {
        "state_file_ids": sorted(state_ids),
        "tiles_to_rebuild": sorted(t.key() for t in rebuild),
        "purge_urls": [f"/tiles/{t}.pbf" for t in sorted(t.key() for t in rebuild)],
    }

Two details in that function are the ones that bite in production. First, a geometry change invalidates the tiles of both the before and after geometry — a parcel that shrank leaves a stale rendering of its old extent in tiles it no longer touches, and invalidating only the new bounds leaves that ghost on the map indefinitely. Second, the ValueError on an unclassified change is deliberate: the planner cannot safely guess, and a new change class that silently falls through to “attribute only” produces a map that is wrong in exactly the way this whole design exists to prevent.

Edge cases and gotchas jump to heading

Tile boundary seams. A parcel crossing a tile edge is clipped into both tiles. If only one is rebuilt, the parcel renders half old and half new, which is more confusing than being entirely stale. The +1 on the tile ranges above is what covers this, and it is worth an explicit test with a parcel placed deliberately on a known tile boundary.

Coordinate precision inside tiles. Vector tiles quantise coordinates to an integer grid, typically 4096 units per tile. At low zoom that grid is coarser than a parcel boundary, so distinct lots collapse onto identical geometry. This is correct behaviour for rendering and wrong for anything else, which is another reason a tile is not an answer.

Feature ids that must be numeric. The vector tile specification’s id field is an unsigned integer, while parcel identifiers are strings like 0714-22-1. A hash into 53 bits works and must be checked for collisions across the whole layer, because a collision silently merges two parcels’ styling. Keep the original string as a regular attribute alongside the numeric id.

A taxonomy change with no data change. When the canonical class set changes — a new RES-MED class splitting an existing one — no parcel changed, so no change record is emitted, and the map’s styling silently has no rule for the new class. Those features render with the fallback style, which usually means invisible. Taxonomy versions therefore need to participate in the style contract, which is the subject of keeping tile styling in step with a taxonomy change.

Stale tiles behind a CDN you do not control. Purge APIs are eventually consistent and occasionally silent. Versioning the tile path — /tiles/v{pyramid_version}/{z}/{x}/{y}.pbf — sidesteps purging entirely for geometry rebuilds, at the cost of a cold cache. For a layer that rebuilds monthly this is usually the better trade.

Integration points jump to heading

Tiling consumes three things and produces one.

It consumes change records with a class, from change detection — the class is what routes a change to a state-file rewrite or a tile rebuild, and an unclassified change stops the planner. It consumes resolved attributes, including the overlay set from overlay district modeling, because a map that shades base zoning while ignoring overlays shows a district that does not govern anything. And it consumes capacity figures where they exist, since allowable floor area is the attribute users most want shaded.

What it produces is a rendering surface, and the discipline that matters is keeping it labelled as one. Tiles should carry an as_of attribute or a sidecar manifest naming the effective date and the run that produced them, so a screenshot taken from the map can be dated later. That manifest is also what lets a stale map be detected rather than merely suspected: a client comparing the manifest’s as_of against the API’s current date can tell the user their map is eleven hours old, which is the same honesty the fallback tiers in fallback routing logic apply to data.

Compliance and audit artifacts jump to heading

Maps get screenshotted, pasted into memos, and cited in meetings, which makes their provenance a compliance concern even though they are “just rendering.”

The pyramid manifest is the primary artifact: pyramid version, generation run id, source snapshot digest, effective date, simplification tolerance per zoom, and the taxonomy version the styling assumes. With it, a screenshot can be traced to the exact data and rules behind it; without it, a map is an undated assertion.

The invalidation log is the second: for each change set, which state ids were rewritten, which tiles were rebuilt, which purges were issued, and which of those purges were confirmed. Unconfirmed purges are the mechanism by which a correct pipeline publishes a wrong map, and they are invisible unless recorded.

Finally, keep the style contract under version control next to the taxonomy. A style file that names classes is a consumer of the taxonomy, and treating it as one — versioned together, tested together, as described in testing spatial data pipelines — is what stops a class rename from quietly erasing a district from the map.

Generating the pyramid jump to heading

Two generation paths dominate, and they suit different publishing rhythms rather than different scales. Pre-generating an archive with a tool such as tippecanoe produces an immutable set of tiles that can be served from object storage with no database in the request path; it is the right choice for a snapshot that will be served unchanged for a month, and it is also the honest choice for an archived historical layer, because the tiles are a file with a digest rather than a query result that may drift.

Tiles to rebuild for one changed parcel, by zoom level Line chart over zoom levels 10 to 16 of how many tiles one changed parcel occupies at each zoom, and the cumulative total across zooms. A parcel occupies one tile at low zooms, two at zoom 13 and 14, four at zoom 15 and nine at zoom 16, because tile size shrinks faster than the parcel does and boundary parcels are clipped into neighbours. The cumulative total reaches twenty tiles for a single parcel across the seven zoom levels. Tiles to rebuild for one changed parcel, by zoom level 0 5 10 15 20 25 z10 z11 z12 z13 z14 z15 z16 Zoom level Tiles containing the parcel tiles at this zoom cumulative tiles to purge
One changed parcel means about 20 tiles across seven zooms — trivial for one rezone, and the reason a county-wide re-code becomes a full pyramid rebuild. The counts include both tiles a boundary parcel is clipped into, which is the off-by-one that leaves a stale seam on the map.

Serving tiles from the database on demand — PostGIS composing them per request — inverts the trade: nothing to invalidate because nothing is stored, at the cost of putting a spatial query in the critical path of every map pan. With a cache in front it behaves like a pre-generated pyramid whose invalidation is a cache purge rather than a rebuild, which for a layer that changes daily is usually the simpler operational story.

The decision that actually matters is not which generator to use but whether attributes are baked in, because that is what determines whether a rezone is a file write or a rebuild. Both generators can work either way, and choosing the join before choosing the tool avoids rebuilding the pipeline when the publishing rhythm changes.

Telling a user how old the map is jump to heading

The most valuable feature a zoning map can have is not a layer toggle; it is a visible statement of what it is showing. A map is a copy, copies go stale, and a stale zoning map is indistinguishable from a current one on screen.

Cache lifetime and invalidation route, by artifact A five-row by three-column matrix of published artifacts. Rows are the geometry tile pyramid, the zoning state file, the pyramid manifest, the style file, and the API used for authoritative answers. Columns are how long each may be cached, how it is invalidated, and what a stale copy costs. The tile pyramid may be cached for a year because it is versioned in its path, so it is never invalidated at all. The state file has a sixty-second lifetime and is invalidated by being overwritten. A stale state file shows the wrong zoning colour; a stale manifest is worse, because it makes the map claim to be fresher than it is. Cache lifetime and invalidation route, by artifact may be cached for invalidated by cost of a stale copy Geometry tile pyramid 1 year a new version in the path old parcel shapes Zoning state file 60 seconds overwrite wrong zoning colour Pyramid manifest 30 seconds overwrite the map lies about its age Style file 5 minutes a new version in the path new classes render invisible Answers API no-store n/a a wrong compliance answer safe cosmetic when stale misleading when stale
The row that matters most is the manifest. A stale tile shows an old shape and a stale state file shows an old colour, but a stale manifest makes the map lie about its own age — which removes the one defence a reader had against acting on old zoning.

Ship an as_of value in the pyramid manifest and surface it in the interface. When the state file carries its own timestamp, a client can compare the two and say something genuinely useful — “zoning as of 09:15 today” or, during an ingestion outage, “zoning as of yesterday evening; one county is being served from a snapshot.” That second message is the same tiering the pipeline already computes for fallback routing, carried one step further into the interface rather than stopping at the API.

The alternative is what most zoning maps do: display polygons with no date and let the user assume they are current. The assumption is usually right, which is exactly what makes the occasional wrong case damaging — a user who has been trained by a hundred correct sessions will not question the hundred-and-first.

FAQ jump to heading

Should zoning attributes be baked into tiles or joined at render time?

Joined, for any map that has to reflect current zoning. Attribute changes are frequent — every rezone is one — and geometry changes are rare, so baking makes the common case an expensive rebuild and the rare case no cheaper. Baking is the better choice only for an immutable archive snapshot, where the point is that nothing will change.

Is it safe to read a zoning code out of a tile?

For rendering, yes; for an answer, no. Tiles quantise coordinates to an integer grid and carry a snapshot of attributes that may be older than the API, so a code read from a tile is a rendering hint rather than a fact about a parcel. Declare the tile as a copy and the API as authoritative, and keep any compliance path off the tile entirely.

Which tiles need invalidating when a parcel's geometry changes?

The tiles covering the geometry before the change as well as after it, across every zoom level served. Invalidating only the new extent leaves a rendering of the old shape in tiles the parcel no longer touches, and that ghost persists until the next full rebuild. Include the tiles the boundary falls on, since a parcel on a tile edge is clipped into both.

What breaks when the zoning taxonomy gains a new class?

The style file, silently. No parcel changed, so no change record is emitted and no tile is rebuilt, but the styling has no rule for the new class and those features fall through to the fallback style — which usually means they vanish. Version the style contract alongside the taxonomy and test that every current class has a matching rule.