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.”
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.
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.
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.
Related jump to heading
- Parent topic: Testing Spatial Data Pipelines
- Section overview: Municipal Zoning Data Architecture & Compliance Frameworks
- Handling EPSG lookup failures in pyproj — the failure modes these tests assert against
- CRS Alignment Strategies — the gate that refuses rather than approximates
- Fallback Routing Logic — what to serve when a transform cannot be done exactly