Environment Parity for Spatial Pipelines

“Works on my machine” is a minor irritation for a web service and a correctness problem for a spatial pipeline, because the thing that differs between machines is the library that decides where things are on the ground. Parity means one image, one set of environment variables and one lock file everywhere the code runs — and, because parity always erodes, a report that names the differences rather than asserting there are none.

When to Use This Pattern

  • Development happens outside the worker image, which it usually does at first.
  • A bug reproduces in one environment and not another, which is the symptom that starts this work.
  • CI runs tests against its own libraries rather than inside the artefact being shipped.
  • Environment variables differ between local and production, which is almost always true and rarely written down.

Complete Working Example

One image, three entry points, and a report that compares them.

# Local development: the fleet's image, with the source mounted.
docker run --rm -it \
  -v "$PWD/src:/app/src:ro" \
  -v "$PWD/tests:/app/tests:ro" \
  --env-file .env.local \
  ghcr.io/example/spatial-worker@${WORKER_DIGEST} \
  bash

# CI: the same digest, no mount, tests run inside the artefact.
docker run --rm --network none \
  ghcr.io/example/spatial-worker@${WORKER_DIGEST} \
  python -m pytest tests/ -q

The parity report is a module that dumps everything which could differ, so two environments can be diffed rather than argued about:

# src/parity.py
from __future__ import annotations

import json
import os
import platform
import sys

import fiona, pyproj, rasterio, shapely
from osgeo import gdal

# Variables that change results or resource use. Absence is as significant as value.
TRACKED_ENV = (
    "PROJ_DATA", "PROJ_NETWORK", "GDAL_DATA", "GDAL_CACHEMAX", "GDAL_NUM_THREADS",
    "GDAL_DISABLE_READDIR_ON_OPEN", "CPL_VSIL_CURL_ALLOWED_EXTENSIONS",
    "VSI_CACHE", "VSI_CACHE_SIZE", "OGR_DRIVER_LIST", "GDAL_DRIVER_LIST",
)

PROBES = {
    # transformation, input, expected — one per grid the pipeline depends on
    "EPSG:4326->EPSG:27700": ((55.9533, -3.1883), (325863.108, 673795.836)),
    "EPSG:4326->EPSG:2154": ((48.8566, 2.3522), (652351.245, 6862142.263)),
}


def report() -> dict:
    transforms = {}
    for name, (src, _expected) in PROBES.items():
        a, b = name.split("->")
        e, n = pyproj.Transformer.from_crs(a, b).transform(*src)
        transforms[name] = [round(e, 3), round(n, 3)]

    return {
        "platform": f"{platform.system()}-{platform.machine()}",
        "python": sys.version.split()[0],
        "versions": {
            "gdal": gdal.__version__.split()[0],
            "gdal_via_rasterio": rasterio.__gdal_version__,
            "gdal_via_fiona": fiona.__gdal_version__,
            "proj": pyproj.proj_version_str,
            "geos": shapely.geos_version_string,
        },
        "env": {k: os.environ.get(k) for k in TRACKED_ENV},
        "drivers": sorted(gdal.GetDriver(i).ShortName
                          for i in range(gdal.GetDriverCount()))[:400],
        "transforms": transforms,
    }


if __name__ == "__main__":
    print(json.dumps(report(), indent=2, sort_keys=True))

Comparing two environments is then a diff, and CI can enforce it:

def test_ci_matches_the_fleet(fleet_report_path) -> None:
    here, fleet = report(), json.loads(Path(fleet_report_path).read_text())
    for section in ("versions", "env", "transforms"):
        assert here[section] == fleet[section], (
            f"{section} differs from the fleet:\n"
            + "\n".join(f"  {k}: local={here[section].get(k)!r} "
                        f"fleet={fleet[section].get(k)!r}"
                        for k in set(here[section]) | set(fleet[section])
                        if here[section].get(k) != fleet[section].get(k))
        )
Where parity is actually lostThe laptop, CI and the fleet agree on GDAL and PROJ versions. The laptop differs on the driver cache and on grid availability, which changes results and memory use.propertylaptopCIfleetGDAL version3.9.23.9.23.9.2PROJ gridsfetchedbakedbakedGDAL_CACHEMAXunset512512probe result1.9 m outexactexactThe versions match, which is the check most teams stop at, and two rows below it do not.
A parity report is worth having precisely because the row that differs is never the row anyone expected.

The environment section of the report treats an unset variable as a value, and that is deliberate. Most parity failures in spatial pipelines are not a variable set differently but a variable set in one place and absent in another — GDAL_CACHEMAX unset on a laptop means a cache sized from physical memory, PROJ_NETWORK unset means whatever the build defaulted to, GDAL_DISABLE_READDIR_ON_OPEN unset means a prefix listing per open. Reporting null rather than omitting the key makes those visible in a diff instead of invisible by absence.

Parameter & Option Reference

Practice What it fixes Spatial notes
Same image everywhere native stack drift Mount the source locally; never install the stack a second way.
Tracked environment list silent defaults Report unset as null; absence is the commonest difference.
Transformation probes grid availability One per grid the pipeline relies on, not one globally.
Driver list in the report format surprises A driver present locally and absent on the fleet is a Friday afternoon.
Tests inside the image CI-only libraries A CI runner’s own GDAL proves nothing about the artefact.
--network none in CI fetched grids Proves the grids are baked rather than downloaded on demand.
Committed fleet report drift over time Regenerated on promotion, diffed on every pull request.
What each level of parity still lets throughMatching requirements files still allows native drift. Matching images still allows environment differences. A parity report closes both and shows what changed.same requirementsstill allows:different GDALdifferent gridsdifferent resultssame imagestill allows:unset variablesdifferent memoryfetched gridsparity reportnames the diff:versions, env, probesdriversin one diffEach column is a real improvement, and only the third one tells you what is wrong when parity breaks anyway.
Parity is not a state that can be reached and held; it is a property that has to be measured, because everything drifts.

Verification & Testing

def test_report_is_stable_within_an_environment() -> None:
    assert report() == report(), "the parity report is not deterministic"


def test_every_tracked_variable_is_set_on_the_fleet(fleet_report) -> None:
    unset = [k for k, v in fleet_report["env"].items() if v is None]
    assert not unset, f"relying on defaults for: {', '.join(unset)}"


def test_probes_match_their_expected_values() -> None:
    for name, (src, expected) in PROBES.items():
        a, b = name.split("->")
        e, n = pyproj.Transformer.from_crs(a, b).transform(*src)
        assert abs(e - expected[0]) < 0.01 and abs(n - expected[1]) < 0.01, name


def test_the_two_gdal_bindings_agree() -> None:
    r = report()["versions"]
    assert r["gdal_via_rasterio"] == r["gdal_via_fiona"] == r["gdal"]


def test_driver_list_matches_the_fleet(fleet_report) -> None:
    local, fleet = set(report()["drivers"]), set(fleet_report["drivers"])
    assert local == fleet, f"only local: {local - fleet}; only fleet: {fleet - local}"

The second test is the one that changes behaviour most. “Relying on defaults” sounds harmless and is the mechanism by which a laptop, a CI runner and a worker end up with three different driver caches, three different threading behaviours and three different read patterns while running identical code. Asserting that every tracked variable is explicitly set — even to the value the default would have given — converts a set of implicit environmental assumptions into a list somebody has looked at.

The investigation the report replacesWithout a parity report, a fleet-only bug is investigated through the code for hours. With one, the diff names the unset variable immediately.“it only fails in production”without a reportread the code, add logging, deploy, wait, repeat — a daywith a reportdiff two filesGDAL_NUM_THREADS: local=2, fleet=nullThe code was never the problem, which is why reading it for a day did not help. The environmentis data, and data can be diffed.
The report costs an afternoon to write and is usually paid back the first time somebody says the bug is not reproducible.

There is a limit to how far parity can be pushed, and knowing where it sits saves a lot of wasted effort. Some differences are irreducible: a laptop has different cores, different memory, no cloud identity and a network with different characteristics. Chasing those produces elaborate local infrastructure that is itself a source of divergence. The useful boundary is between differences that change results and differences that change performance — the first must be eliminated, the second must merely be known. A local run that is four times slower is fine; a local run that transforms a coordinate to a different place is not, and the report is arranged to make the second kind obvious while not pretending the first does not exist.

That boundary also decides what belongs in the report. Versions, environment variables, driver availability and probe results all change what the pipeline computes, so they are compared strictly. Core count, memory size and network latency change how long it takes, so they are worth recording for context and not worth asserting on. Mixing the two produces a check that fails constantly for reasons nobody can act on, which is the fastest way to have it disabled.

Common Pitfalls

  • A second way to install the stack for development. The moment a laptop has its own GDAL, parity is a matter of coincidence.
  • Testing in CI against the runner’s libraries. It proves the tests pass somewhere that is not the artefact being shipped.
  • Omitting unset variables from the report. Absence is the commonest difference and the easiest to miss.
  • One transformation probe for a multi-jurisdiction pipeline. It proves one grid is present and says nothing about the others.
  • Treating parity as achieved. It erodes with every base-image bump and every new deployment target; measure it.
  • Ignoring the driver list. A driver present locally and missing on the fleet turns a working local test into a production failure on an unusual delivery.

Frequently Asked Questions

Is running the fleet image locally practical?

Yes, with the source mounted read-only and an interactive shell. It is slower than a native environment on macOS and it is the only arrangement in which a local reproduction means anything. Where speed matters more than fidelity — quick iteration on pure logic — use a native environment for that and the image for anything touching the libraries.

What if developers use Apple silicon and the fleet is x86?

Then the platform genuinely differs and the report will say so, which is better than pretending otherwise. Build a multi-architecture image from the same lock specification, keep the probes identical, and treat any difference in the transforms section as a blocking bug rather than a platform quirk.

How is the fleet report produced?

By the worker itself, at boot, written to a known location, and captured at promotion time. Producing it from the image rather than from a description of the image is the point; a hand-maintained record of what production looks like is a document, not a measurement.

Should the report include the full driver list?

A truncated, sorted list is enough and keeps the diff readable. What matters is that a driver appearing or disappearing shows up; the exact ordering of a hundred and forty names does not.

Does this replace integration tests?

No — it explains their results. A test that passes locally and fails on the fleet is uninterpretable without knowing what differs, and that is what the report supplies. See testing spatial flows in CI with synthetic fixtures.

Deployment & CI/CD for Spatial Workers