Skipping Tiles With No New Source Data
A tile may be skipped when every source whose footprint touches it — buffered by the resampling kernel’s reach — has the same content digest it had when the tile was last built. That is a spatial join, not a timestamp comparison, and getting it wrong in either direction is costly: skip too eagerly and the mosaic keeps stale pixels indefinitely; skip too cautiously and the nightly run rebuilds a continent because one scene moved. Record the decision and the digests behind it, so a tile that looks wrong can be traced to the run that chose not to rebuild it.
When to Use This Pattern
- Sources update independently and infrequently — a national imagery programme republishing a few scenes a week against a pyramid of millions of tiles.
- Rebuilding everything nightly is affordable but wasteful, and the waste is now large enough to notice on the bill.
- Tiles are built from more than one source, so “did the source change?” is genuinely a set question.
- You already record which sources built each tile, or can start — without that record this pattern cannot be implemented correctly at all.
Complete Working Example
The join runs in PostGIS, because the tile grid and the source footprints are both spatial and the question is a spatial predicate. The build ledger records which digests produced each tile.
-- What produced each tile, and from what. One row per tile per build.
CREATE TABLE tile_builds (
z smallint NOT NULL,
x integer NOT NULL,
y integer NOT NULL,
geom geometry(Polygon, 3857) NOT NULL, -- the tile's own footprint
source_state jsonb NOT NULL, -- {"scene_id": "digest", …} at build time
built_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (z, x, y)
);
CREATE INDEX tile_builds_gist ON tile_builds USING gist (geom);
-- Current source inventory: footprint and content digest per scene.
CREATE TABLE source_scenes (
scene_id text PRIMARY KEY,
geom geometry(Polygon, 3857) NOT NULL,
digest text NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE INDEX source_scenes_gist ON source_scenes USING gist (geom);
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterator
import psycopg
# Metres of reach at this zoom, from the resampling kernel. A tile whose edge sits
# inside a changed scene's buffer changes even if its centre does not.
KERNEL_REACH_PX = {"nearest": 0, "bilinear": 1, "cubic": 2, "lanczos": 3}
STALE_TILES = """
WITH buffered AS (
SELECT scene_id, digest, ST_Buffer(geom, %(reach_m)s) AS geom
FROM source_scenes
),
touching AS (
SELECT t.z, t.x, t.y, t.source_state,
jsonb_object_agg(b.scene_id, b.digest) AS current_state
FROM tile_builds t
JOIN buffered b ON ST_Intersects(t.geom, b.geom)
WHERE t.z = %(zoom)s
GROUP BY t.z, t.x, t.y, t.source_state
)
SELECT z, x, y, source_state, current_state
FROM touching
-- The whole predicate: the set of (scene, digest) pairs touching this tile now
-- differs from the set that produced it. Comparing objects, not timestamps.
WHERE source_state IS DISTINCT FROM current_state
"""
@dataclass(frozen=True)
class TileDecision:
z: int
x: int
y: int
rebuild: bool
reason: str
def tiles_to_rebuild(conn: psycopg.Connection, zoom: int,
resampling: str = "bilinear") -> Iterator[TileDecision]:
reach_m = KERNEL_REACH_PX.get(resampling, 1) * resolution_at(zoom)
with conn.cursor() as cur:
cur.execute(STALE_TILES, {"zoom": zoom, "reach_m": reach_m})
for z, x, y, was, now in cur:
changed = sorted(
scene for scene in set(was) | set(now)
if was.get(scene) != now.get(scene)
)
yield TileDecision(z, x, y, True,
f"changed sources: {', '.join(changed[:5])}")
def record_build(conn: psycopg.Connection, z: int, x: int, y: int,
source_state: dict[str, str]) -> None:
"""Write the state the tile was built FROM, at the moment it was built."""
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO tile_builds (z, x, y, geom, source_state, built_at)
VALUES (%s, %s, %s, ST_TileEnvelope(%s, %s, %s), %s::jsonb, now())
ON CONFLICT (z, x, y) DO UPDATE
SET source_state = EXCLUDED.source_state, built_at = now()
""",
(z, x, y, z, x, y, json.dumps(source_state, sort_keys=True)),
)
The source_state column is the part worth getting right first, because everything else follows from it. It has to record the state the tile was built from, captured at build time and never updated afterwards — which means writing it in the same transaction that records the build, from the values the build actually used rather than from a fresh query. A source_state refreshed at read time is not a record of anything; it always equals the current state, so nothing ever rebuilds and the pyramid quietly freezes.
Parameter & Option Reference
| Parameter | Type | Default | Spatial notes |
|---|---|---|---|
source_state |
jsonb |
required | The set of (scene_id, digest) pairs at build time. Comparing sets, not timestamps, is what makes this correct. |
KERNEL_REACH_PX |
dict | 0–3 | In source pixels, converted to metres per zoom. Lanczos reaches three times as far as bilinear. |
ST_TileEnvelope |
function | — | PostGIS 3.0+. Produces the tile’s exact Web Mercator footprint without hand-rolled arithmetic. |
| GiST indexes | required | both tables | The join is ST_Intersects over millions of rows; without indexes it is a sequential scan per zoom. |
| zoom range | 0…max |
all | Run the join per zoom, since the buffer is zoom-dependent. |
IS DISTINCT FROM |
operator | — | Handles nulls correctly: a tile with no recorded state must rebuild, not compare equal. |
Verification & Testing
The properties are that an unchanged world skips everything, and that a single changed scene rebuilds its neighbourhood.
def test_unchanged_world_skips_everything(conn, seeded_pyramid) -> None:
assert list(tiles_to_rebuild(conn, zoom=12)) == []
def test_one_changed_scene_rebuilds_its_neighbourhood(conn, seeded_pyramid) -> None:
update_scene_digest(conn, scene_id="S2_20260807_T33WXP", digest="sha256:new")
decisions = list(tiles_to_rebuild(conn, zoom=12))
ids = {(d.x, d.y) for d in decisions}
assert ids >= seeded_pyramid.tiles_covering("S2_20260807_T33WXP")
# …and strictly more than the footprint, because of the buffer.
assert ids > seeded_pyramid.tiles_covering("S2_20260807_T33WXP")
assert all("S2_20260807_T33WXP" in d.reason for d in decisions)
def test_a_tile_with_no_history_always_rebuilds(conn) -> None:
"""IS DISTINCT FROM must treat a missing record as different, not as equal."""
insert_tile_without_state(conn, z=12, x=2100, y=1300)
assert (2100, 1300) in {(d.x, d.y) for d in tiles_to_rebuild(conn, zoom=12)}
In production the number worth watching is the skip ratio against the source change rate. If ten scenes out of four hundred changed and the pipeline rebuilt sixty per cent of the pyramid, the join is over-matching — usually a buffer computed in the wrong units, or a source_state that includes a value which changes on every publication regardless of content:
SELECT count(*) FILTER (WHERE built_at > now() - interval '1 day') AS rebuilt,
count(*) AS total,
round(100.0 * count(*) FILTER (WHERE built_at > now() - interval '1 day')
/ count(*), 1) AS pct
FROM tile_builds
WHERE z = 12;
Common Pitfalls
- Comparing timestamps instead of digests. A source republished with identical content has a new
updated_atand the same digest. Keying on the timestamp rebuilds for nothing, every time an upstream job runs. - Forgetting the buffer. A tile at the edge of a changed scene draws pixels from just outside it. Without the buffer those tiles are skipped and a seam appears — the same failure as invalidating tile caches after a source update.
- Buffering in pixels rather than metres. A pixel is 150 m at zoom 10 and 0.6 m at zoom 18. One number applied everywhere over-matches at one end and under-matches at the other.
- Treating a missing build record as “unchanged”. A tile that has never been built has no
source_state, and= NULLis never true.IS DISTINCT FROMgets this right;<>silently skips every tile the pipeline has not seen. - Not recording the reason. “Tile 12/2100/1300 was skipped” is not actionable. “Skipped: sources S2_A(sha256:ab…), S2_B(sha256:cd…) unchanged since 2026-08-01” is, and it costs one column.
Frequently Asked Questions
What if a tile is built from a source that has been withdrawn?
The set comparison catches it: the withdrawn scene is in source_state and absent from current_state, so the sets differ and the tile rebuilds. That is correct — a tile built from imagery that no longer exists should be regenerated without it. This is one of several cases where comparing sets rather than counting changes gives the right answer for free.
Does this work when tiles are built from a database rather than from scenes?
Yes, with a different notion of digest. For a PostGIS-backed pyramid, the equivalent is a per-extent version — a max updated_at or a hash of the feature ids and their geometry versions within the tile’s envelope. It is more expensive to compute than an object-store digest, so it is usually worth maintaining incrementally with a trigger rather than computing it per run.
How do I bootstrap this on an existing pyramid?
Backfill tile_builds with the current source state and a built_at of now, then accept that the first run after the backfill skips everything. If the pyramid may already contain stale tiles, do the opposite: backfill with an empty source_state, which forces one full rebuild and puts every tile on a known footing. The second option costs a night and removes all doubt.
Should the skip decision fan out to parent tiles?
Yes, and the same join gives it: a parent tile’s source_state is the union of its children’s, so a changed child changes the parent’s state and the parent rebuilds. Running the query per zoom, from the deepest upward, propagates naturally without any extra logic.
Related
- Conditional branching in geospatial DAGs — recording the decision this produces
- Branching workflows based on spatial extent — the other predicate in the same inspection
- Invalidating tile caches after a source update — the same buffer arithmetic, applied to a cache
- Caching strategies for spatial tasks — where the digests come from