Testing CRS handling without a live projection database

The reprojection tests pass on every developer machine and in CI. They also pass in the production container, which ships a slim PROJ install without the NADCON grid files — where the same transformation silently substitutes a Molodensky approximation and moves every parcel by up to 4.6 metres. Nothing raised, no test failed, and the overlay results have been quietly wrong for a month. The problem is not that the tests are weak; it is that they assert only the success path, and the failure they needed to catch is a substitution, not an error. This guide tests CRS handling so that a missing grid is loud, using pinned data, an explicit presence assertion, and tests for the refusal path. It extends the reprojection assertions in testing spatial data pipelines.

Diagnosis: what your environment actually has jump to heading

Ask PROJ what it is going to do before trusting what it did. The information is available and almost never checked.

import pyproj
from pyproj.transformer import TransformerGroup


def crs_environment() -> dict:
    """Everything that decides whether a transform is exact or approximated."""
    return {
        "proj_version": pyproj.proj_version_str,
        "pyproj_version": pyproj.__version__,
        "data_dir": pyproj.datadir.get_data_dir(),
        "network_enabled": pyproj.network.is_network_enabled(),
    }


def transform_quality(src: str, dst: str) -> dict:
    """TransformerGroup lists EVERY available path, best first, and says whether any
    required grid is missing. This is the call that distinguishes an exact transform
    from a silent approximation."""
    group = TransformerGroup(src, dst)
    best = group.transformers[0] if group.transformers else None
    return {
        "available_paths": len(group.transformers),
        "unavailable_operations": [str(op) for op in group.unavailable_operations],
        "best_description": best.description if best else None,
        # The property that matters: PROJ knows a better path exists and cannot use it.
        "grid_missing": bool(group.unavailable_operations),
        "best_accuracy_m": getattr(best, "accuracy", None) if best else None,
    }

Run that in every environment you deploy to. A developer machine typically reports zero unavailable operations; a slim container reports several, and the transformation still “works.”

The same transform in three environments A three-row by four-column matrix of environments running the same NAD27 to NAD83 transform. Rows are a developer machine with full PROJ data, a continuous integration runner with network fetching enabled, and a slim production container. Columns are whether the required grid is present, which transform path PROJ selects, the resulting positional error, and whether anything raises. Only the production container lacks the grid, silently substitutes a Molodensky approximation with 4.6 metres of error, and raises nothing. The same transform in three environments grid present? path PROJ selects positional error raises? Developer machine (full PROJ data) yes NADCON5 grid 0.15 m no — correct CI runner (network fetching on) downloaded on demand NADCON5 grid 0.15 m no — masked Slim production container no Molodensky approximation 4.6 m no — silent correct masks the problem silently wrong
Three environments, three different transforms, one test result: pass. The production row is the one that matters and it is the only one the suite never runs in — which is why the session fixture asserts the grid is present rather than trusting that the transform succeeded.

Step-by-step implementation jump to heading

1. Pin the PROJ data and assert its presence once per session jump to heading

# conftest.py
import pyproj
import pytest

# The exact grids this pipeline's transformations require. Each entry exists because
# a specific transform needs it, and the comment says which.
REQUIRED_GRIDS = {
    "us_noaa_nadcon5_nad27_nad83_conus.tif": "NAD27 → NAD83 (legacy county layers)",
    "us_noaa_conus.tif": "NAD83 → NAD83(2011) (modern realisations)",
}
EXPECTED_PROJ_MAJOR = 9


@pytest.fixture(scope="session", autouse=True)
def crs_environment_is_sane():
    """Fails the whole suite in an environment that would silently approximate.
    Autouse and session-scoped on purpose: this is a precondition, not a test."""
    import pathlib

    major = int(pyproj.proj_version_str.split(".")[0])
    assert major == EXPECTED_PROJ_MAJOR, (
        f"PROJ {pyproj.proj_version_str} — pinned to {EXPECTED_PROJ_MAJOR}.x because "
        f"noding and transform selection change between majors")

    data_dir = pathlib.Path(pyproj.datadir.get_data_dir())
    missing = [g for g in REQUIRED_GRIDS if not (data_dir / g).exists()]
    assert not missing, (
        "missing PROJ grid file(s): "
        + ", ".join(f"{g} (needed for {REQUIRED_GRIDS[g]})" for g in missing)
        + f"\nPROJ_DATA={data_dir}. Install proj-data or mount the grids; without "
          "them transforms silently fall back to an approximation.")

    # Network fetching would mask a missing grid in CI and fail in production, where
    # egress is usually blocked. Off, explicitly.
    pyproj.network.set_network_enabled(False)

2. Test the substitution, not just the result jump to heading

def test_no_transform_silently_approximates():
    """The test the original suite was missing. For every transform pair the pipeline
    uses, assert PROJ is not aware of a better path it cannot take."""
    for src, dst in PIPELINE_TRANSFORMS:            # e.g. [("EPSG:4267", "EPSG:26913"), …]
        q = transform_quality(src, dst)
        assert not q["grid_missing"], (
            f"{src} → {dst}: PROJ would use {q['best_description']!r} because "
            f"{q['unavailable_operations']} are unavailable — this is the silent "
            f"approximation, not an error")


def test_declared_accuracy_is_within_the_pipelines_budget():
    for src, dst in PIPELINE_TRANSFORMS:
        q = transform_quality(src, dst)
        acc = q["best_accuracy_m"]
        assert acc is not None and acc <= 0.10, (
            f"{src} → {dst}: best available accuracy {acc} m exceeds the 0.10 m budget")

3. Test the refusal path deliberately jump to heading

Most CRS bugs in production are not wrong transforms — they are absent ones that were allowed through. That path needs its own tests.

Five CRS conditions, and whether a success-path test would notice A five-row by three-column matrix of CRS conditions. Rows are a correctly declared CRS, no declared CRS at all, a declared code that disagrees with the coordinates, a WKT definition with no authority code, and a missing transform grid. Columns are what the pipeline should do, whether a test that only asserts the success path would notice, and which assertion catches it. Four of the five are invisible to a success-path test, and each needs an explicit refusal test. Five CRS conditions, and whether a success-path test would notice pipeline should a success-path test would notice? assertion that catches it CRS correctly declared transform yes the existing test No declared CRS refuse no raises on crs is None Declared code ≠ coordinates refuse no coordinate-range assertion WKT with no authority code refuse no to_epsg() is not None Transform grid missing refuse no TransformerGroup has no unavailable ops covered the required behaviour invisible
Four of five conditions are invisible to a test that only checks the happy path — and all four are conditions where the pipeline produces coordinates rather than an error. The refusal path needs its own tests precisely because nothing in the success path ever exercises it.
def test_batch_without_a_declared_crs_is_refused():
    gdf = gpd.GeoDataFrame({"geometry": [box(0, 0, 10, 10)]})   # crs is None
    with pytest.raises(CRSGateError, match="no declared CRS"):
        crs_gate(gdf)


def test_declared_code_that_disagrees_with_the_coordinates_is_refused():
    # Coordinates are clearly state plane feet; the frame claims WGS84 degrees.
    gdf = gpd.GeoDataFrame({"geometry": [box(3_100_000, 1_400_000,
                                             3_100_100, 1_400_100)]},
                           crs="EPSG:4326")
    with pytest.raises(CRSGateError, match="range"):
        crs_gate(gdf)


def test_to_epsg_returning_none_is_refused_before_writing():
    """A WKT-defined CRS with no authority code. to_epsg() returns None, nothing
    raises, and the written file has no projection."""
    crs = pyproj.CRS.from_wkt(WKT_WITHOUT_AUTHORITY)
    assert crs.to_epsg() is None
    with pytest.raises(CRSGateError, match="authority"):
        assert_writable_crs(crs)

4. Test the transform logic without PROJ at all jump to heading

Some of the pipeline’s CRS logic is decision-making rather than mathematics — which transform to select, whether to refuse, what to record — and that part should be testable with no projection database present, because it is where most of the bugs are.

Test runtime by what the test actually needs Lollipop chart of runtime in milliseconds for four kinds of CRS test. A decision test using a fake transformer takes about 0.4 milliseconds. A real transform of a single polygon takes about 12. A transform of a thousand parcels takes about 340. A session-scoped grid-presence assertion takes about 60 milliseconds once. A dashed rule marks 50 milliseconds, above which a test stops being runnable on every save. Test runtime by what the test actually needs 0 100 200 300 400 50 ms — above this, not a per-save test decision logic, fake transformer 0.4 ms one real transform 12 ms grid-presence assertion (once per session) 60 ms 1 000 parcels, real transform 340 ms Milliseconds
The decision logic — which transform to pick, whether to refuse, what to record — is where most CRS bugs live, and a fake transformer exercises it thirty times faster than the real library. That is what makes exhaustive refusal-path testing affordable.
class FakeTransformer:
    """A deterministic stand-in: a fixed offset plus a scale, with a declared
    accuracy and an optional 'grid missing' flag. Lets the SELECTION and REFUSAL
    logic be tested exhaustively in milliseconds, with no PROJ data at all."""

    def __init__(self, dx, dy, scale=1.0, accuracy=0.01, grid_missing=False):
        self.dx, self.dy, self.scale = dx, dy, scale
        self.accuracy, self.grid_missing = accuracy, grid_missing

    def transform(self, x, y):
        return x * self.scale + self.dx, y * self.scale + self.dy


def test_gate_rejects_a_transformer_with_a_missing_grid():
    t = FakeTransformer(0, 0, grid_missing=True)
    with pytest.raises(CRSGateError, match="grid"):
        reproject_with(t, PARCEL)


def test_gate_rejects_an_accuracy_outside_the_budget():
    t = FakeTransformer(0, 0, accuracy=4.6)          # the Molodensky case
    with pytest.raises(CRSGateError, match="accuracy"):
        reproject_with(t, PARCEL)


def test_round_trip_check_catches_a_non_invertible_transform():
    """A transform whose inverse is not itself — which is exactly how an
    approximation behaves — must fail the round-trip assertion."""
    forward = FakeTransformer(10, 10, scale=1.0)
    inverse = FakeTransformer(-10, -10, scale=1.0001)     # subtly not the inverse
    with pytest.raises(AssertionError, match="round trip"):
        assert_round_trips(PARCEL, forward, inverse, tolerance_m=0.01)

Verification & testing jump to heading

Two checks belong in CI itself rather than in the suite.

Run the suite in the production image. A test suite executed only in a fat development image cannot detect the slim-container problem, which is the entire failure this page is about. Running the session fixture inside the deployment image is a thirty-second job that catches it.

Record the environment with the results. Emit crs_environment() into the test report, so a green run from six months ago can be interrogated for which PROJ version and data directory produced it. Without that, a passing historical run proves nothing about the transformation it exercised.

def test_environment_is_recorded(record_property):
    env = crs_environment()
    for k, v in env.items():
        record_property(f"crs_{k}", str(v))
    assert env["data_dir"], "PROJ data directory is unset"

Failure recovery jump to heading

A month of output produced by an approximated transform. Determine the actual error by re-transforming a sample with the grids installed and comparing: if the shift exceeds the tolerance that matters for parcel decisions — around half a metre — the affected geometry has to be re-derived from the source. The lineage records identify precisely which runs are affected, which is what makes the blast radius knowable.

The grids cannot be installed in the production image. Then the pipeline must refuse the transforms that need them rather than approximating. An explicit refusal degrades a jurisdiction to held, which fallback routing logic already knows how to serve; a silent approximation produces plausible wrong coordinates nobody detects.

Network fetching masked the problem in CI. PROJ can download grids on demand, which makes CI pass and production fail where egress is blocked. Disable it explicitly in the session fixture, as above, so both environments exercise the same code path.

Frequently asked questions jump to heading

Why is a missing grid worse than a failed transform?

Because a failure stops the pipeline and a substitution does not. PROJ falls back to a lower-accuracy path, returns coordinates in the right part of the world, and reports success — so the error propagates into overlays and compliance answers looking exactly like correct output. A hard failure would have cost an afternoon; the substitution costs a month of wrong results.

Can CRS logic really be tested without PROJ?

The decision logic can, and that is where most bugs live: which transform to select, whether to refuse an undeclared CRS, what accuracy to demand, what to record. A fake transformer with a declared accuracy and a grid-missing flag exercises all of it in milliseconds. The mathematics still needs the real library, which is what the control-point test is for.

Should PROJ network fetching be enabled in tests?

No. It papers over a missing grid in CI and then fails in production, where egress is usually blocked — so the two environments take different code paths and CI stops predicting anything. Disable it explicitly and assert the grids are present on disk instead.

How specific should the PROJ version pin be?

Pin the major version at minimum, and record the full version with every test run. Transform selection and noding behaviour change between majors, so a suite that was green on PROJ 8 says nothing about PROJ 9. Recording the exact version is what lets you reinterpret an old green run rather than merely trusting it.