Rolling Out GDAL Upgrades Without Breaking Flows

A GDAL upgrade rarely breaks a pipeline; it changes its output. A resampling default moves, a driver starts honouring a tag it used to ignore, a transformation picks a different pipeline from an updated database — and the run stays green while the tiles differ. So the rollout procedure is not “deploy and watch for errors” but “build both, run both on the same real inputs, and diff the results against a tolerance you decided in advance”.

When to Use This Pattern

  • The native stack version is changing — GDAL, PROJ, GEOS, or the grid data.
  • Outputs are published to consumers who would notice a shift, or who would not notice and should.
  • A previous upgrade caused a surprise, which is the experience that makes this obviously worth an afternoon.
  • The pipeline has a canary pool or can have one, since the comparison needs somewhere to run.

Complete Working Example

A shadow run builds the same tiles under both digests and compares them numerically.

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

import numpy as np
import rasterio
from prefect import flow, task, get_run_logger


@dataclass(frozen=True)
class TileDiff:
    tile: str
    identical: bool
    max_abs: float
    changed_fraction: float
    shifted: bool                      # geotransform moved: never acceptable


@task(timeout_seconds=300)
def compare(old: Path, new: Path, tile: str) -> TileDiff:
    with rasterio.open(old) as a, rasterio.open(new) as b:
        if a.transform != b.transform or a.crs != b.crs:
            return TileDiff(tile, False, float("inf"), 1.0, shifted=True)
        x, y = a.read(masked=True), b.read(masked=True)
        delta = np.abs(x.astype("float64") - y.astype("float64"))
        return TileDiff(
            tile=tile,
            identical=bool(np.all(delta == 0)),
            max_abs=float(delta.max()),
            changed_fraction=float((delta > 0).sum() / delta.size),
            shifted=False,
        )


@flow(name="stack-upgrade-shadow")
def shadow(tiles: list[Tile], old_digest: str, new_digest: str,
           max_abs_allowed: float = 1.0, max_changed_fraction: float = 0.02):
    log = get_run_logger()
    old_out = run_in_image(old_digest, tiles, out=Path("/scratch/old"))
    new_out = run_in_image(new_digest, tiles, out=Path("/scratch/new"))

    diffs = compare.map(old=old_out, new=new_out, tile=[t.path for t in tiles])
    results = [d.result() for d in diffs]

    shifted = [d for d in results if d.shifted]
    if shifted:
        raise RuntimeError(f"{len(shifted)} tile(s) moved on the ground: {shifted[:5]}")

    identical = sum(d.identical for d in results)
    worst = max(results, key=lambda d: d.max_abs)
    log.info("shadow: %d/%d byte-identical, worst delta %.3f on %s",
             identical, len(results), worst.max_abs, worst.tile)

    over = [d for d in results
            if d.max_abs > max_abs_allowed or d.changed_fraction > max_changed_fraction]
    if over:
        raise RuntimeError(
            f"{len(over)} tile(s) exceed tolerance (worst {worst.max_abs:.3f} "
            f"on {worst.tile}) — review before promoting"
        )
    return {"identical": identical, "total": len(results), "worst": worst.max_abs}

The rollout itself is then a promotion with a recorded digest per output:

@task
def publish(tile: Tile, path: Path, image_digest: str) -> str:
    # The digest travels with the product, so "which tiles came from the new stack"
    # is a query rather than an inference from timestamps.
    return upload_with_metadata(path, key=tile.path,
                                metadata={"stack-digest": image_digest})
Build both, compare, then decideFour hundred tiles are built under the old and new digests. Three hundred and eighty-one are byte-identical, twenty-eight differ within tolerance and three exceed it.GDAL 3.9.2GDAL 3.10.0same 412 tilespixel diff381 byte-identical28 within tolerance3 overlook at these before promotingThe three are usually one source with an unusual property — a nodata convention, an odd band count —and finding them here costs an hour rather than a republication.
The comparison is the whole procedure. Watching the new build for errors would have reported success in all three cases.

The geotransform check is separate from the pixel tolerance, and it is not negotiable in the way the tolerance is. A changed pixel value within a fraction of a digital number is a resampling difference and usually acceptable; a changed transform means the tile covers different ground, which makes every value in it wrong regardless of how close the numbers look. Treating them as one comparison, with one threshold, loses that distinction — and a shifted tile can easily produce a smaller numerical delta than a legitimate resampling change, because adjacent ground looks similar.

Parameter & Option Reference

Setting Typical Spatial notes
Shadow sample 200–500 real tiles Sampled across sources and geographies, not one contiguous block.
max_abs_allowed 1 DN for imagery, 0 for categorical A land-cover class map has no acceptable non-zero delta.
max_changed_fraction 0.02 Catches a change that is small everywhere, which a max-only test misses.
Transform equality exact Non-negotiable. A moved tile is wrong however small the pixel diff.
CRS equality exact Same reasoning, and it catches an updated authority database.
Digest in output metadata always Makes “which products came from the new stack” a query.
Soak period one full cycle Including a backfill, which exercises paths a nightly run does not.
Four kinds of difference, three verdictsByte-identical output is ideal. Small resampling differences are acceptable. A large delta on a subset needs investigation. A moved geotransform is always a blocker.differencelikely causeverdictnonenothing relevant changedpromote±1 DN, scatteredresampling roundingpromotelarge, one sourcenodata or mask handlinginvestigategeotransform movedgrid or authority changestopThe third row is where the real findings are: a change confined to one source is a property of that source.
Sorting the differences by cause rather than by magnitude is what makes the shadow run actionable.

Verification & Testing

def test_shadow_blocks_a_moved_transform(monkeypatch, tmp_path) -> None:
    old = write_tile(tmp_path / "a.tif", transform=T)
    new = write_tile(tmp_path / "b.tif", transform=T_shifted_half_pixel)
    d = compare.fn(old, new, "12/2147/1398")
    assert d.shifted and not d.identical


def test_small_scattered_delta_passes(tmp_path) -> None:
    old = write_tile(tmp_path / "a.tif", data=BASE)
    new = write_tile(tmp_path / "b.tif", data=BASE + jitter(max_abs=1, fraction=0.01))
    d = compare.fn(old, new, "t")
    assert d.max_abs <= 1.0 and d.changed_fraction <= 0.02


def test_widespread_small_delta_is_caught(tmp_path) -> None:
    # Every pixel off by one is not "within tolerance": something systematic moved.
    old = write_tile(tmp_path / "a.tif", data=BASE)
    new = write_tile(tmp_path / "b.tif", data=BASE + 1)
    d = compare.fn(old, new, "t")
    assert d.changed_fraction > 0.02


def test_categorical_layer_uses_zero_tolerance(monkeypatch) -> None:
    with pytest.raises(RuntimeError, match="exceed tolerance"):
        shadow(LANDCOVER_TILES, OLD, NEW, max_abs_allowed=0.0)

The third test is the one that justifies having two thresholds rather than one. An upgrade that shifts every pixel by a single digital number produces a maximum delta of one, which a max-only rule waves through — and yet a systematic, universal shift is exactly the signature of a changed scaling or a changed nodata interpretation, which is much more significant than a few scattered rounding differences of the same magnitude. Checking how much of the image changed alongside how much it changed by separates the two.

Same maximum, different meaningA scattered one-unit difference on one per cent of pixels is rounding. A one-unit difference on every pixel is a systematic change, and both have a maximum delta of one.both have max delta = 11% of pixelsrounding — promote100% of pixelssystematic — investigate the scalingA rule that only looks at the maximum promotes both. The changed fraction is what separates them.
Two numbers describe a diff adequately; one does not, and the one usually chosen is the less informative.

One organisational point makes the difference between this procedure being followed and being skipped: the shadow run has to be cheap enough to run on every upgrade, including the boring ones. A patch release that nobody expects to change anything is exactly when a change slips through, because that is the release nobody bothered to shadow. Keeping the sample to a few hundred tiles, running both builds on the canary pool overnight and producing a one-line summary — “381 of 412 byte-identical, worst delta 0.4” — makes it a routine step rather than a project, and a routine step survives a busy month.

The summary line is worth designing as carefully as the comparison. It goes into the promotion record, and six months later it is the only evidence of what an upgrade did. “Promoted GDAL 3.10.0; 381/412 identical, 28 within ±1 DN, 3 investigated and traced to a nodata convention in the coastal source” is a sentence that answers a question somebody will eventually ask. “Upgraded GDAL, all green” is not, and the two cost the same to write.

Common Pitfalls

  • Deploying and watching for errors. The failure mode of a stack upgrade is a changed number, not an exception.
  • One tolerance for every layer. Imagery tolerates a digital number of rounding; a categorical raster tolerates nothing.
  • Comparing only the maximum delta. A universal one-unit shift is systematic and passes a max-only rule.
  • Shadowing on synthetic fixtures. They are for CI. The shadow needs real sources, because the surprises come from real sources’ quirks.
  • No digest recorded on outputs. After a bad promotion, identifying affected products becomes an inference from timestamps.
  • Skipping the backfill during the soak. Backfills exercise code paths a nightly run does not, and they are where a driver change tends to surface.

Frequently Asked Questions

How many tiles should the shadow run cover?

Enough to include every source and every processing path — usually two to five hundred, chosen across sources and geographies rather than as a contiguous block. A shadow over one region tests one source’s quirks and misses the rest.

What if the new version is genuinely better?

That is a common and welcome outcome: a fixed nodata handling, a more accurate transformation. The shadow run does not decide whether the change is good, only that it exists and how large it is. Accepting it deliberately, with a note in the release, is a completely different thing from discovering it later.

Should consumers be told?

If outputs changed, yes. A layer that shifted by half a pixel or whose values moved by a digital number is a real change to somebody’s analysis, and a short note costs nothing compared to the conversation that starts when they find it themselves.

How is a rollback handled?

Revert the digest, which restores behaviour immediately, and then decide about the products already published under the new stack. Because the digest is in each output’s metadata, that set is a query; without it, the answer is guesswork based on timestamps.

Does this apply to PROJ grid updates too?

Especially. A grid update is a deliberate accuracy improvement that moves coordinates, which is precisely the change the transform check will refuse — correctly, because it should be a decision rather than a side effect of a base-image bump.

Deployment & CI/CD for Spatial Workers