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)),
        )
Which tiles a single changed scene reachesThree source scenes overlap a tile grid. One scene changed. The tiles that intersect its buffered footprint are rebuilt; the rest are skipped with the reason recorded.changed scene footprintrebuilt — inside the bufferskipped — digests unchanged4 tiles inside the footprint,8 rebuilt once the buffer is applied,7 skipped out of 15.
The ring of amber tiles is the part a naive footprint intersection misses. Without them the mosaic shows a seam exactly where the new imagery meets the old.

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.

The state must be a snapshot, not a viewA source state captured at build time differs from the current state when a scene changes, so the tile rebuilds. A state re-read at query time always matches the current state, so nothing ever rebuilds.CAPTURED AT BUILD TIMEbuilt with A:ab12, B:cd34stored, then frozennow A:ab12, B:ef56B was republisheddiffers → rebuildRE-READ AT QUERY TIMEstate = current sourcesrecomputed every timenow A:ab12, B:ef56B was republishedidentical → skip foreverthe pyramid freezes
The lower row is a bug that presents as excellent efficiency: skip rates approach a hundred per cent and the compute bill drops, right up until someone notices the imagery is a year old.

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;
Rebuild share should track source change shareOver four weeks the share of tiles rebuilt tracks the share of sources changed, except in week three where the rebuild share jumps to sixty per cent while sources changed by three per cent.60%30%wk 1wk 2wk 3wk 4buffer in pixels, not metres — over-matchingsources changed (green)Week three cost twenty times the compute and produced pixel-identical output for 57% of the pyramid.
The two lines should stay close and roughly parallel. A divergence in either direction is a bug in the join, not a change in the data.

Common Pitfalls

  • Comparing timestamps instead of digests. A source republished with identical content has a new updated_at and 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 = NULL is never true. IS DISTINCT FROM gets 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.

Conditional Branching in Geospatial DAGs