Detecting Missing PROJ Grid Files in CI
A PROJ datum grid is a file of interpolated shifts, derived from survey observations, that turns an approximate datum transformation into an accurate one. When the file is absent PROJ does not fail — it silently selects a coarser operation and carries on, moving every coordinate by one to two metres in the same direction. This guide sits beneath coordinate reference system testing and shows how to detect that condition explicitly in CI, before a suite spends minutes producing geometry results that are all consequences of it. The check is three lines long, runs in milliseconds, and eliminates the most common cause of a pipeline that is correct locally and quietly wrong on a runner.
Why the absence is invisible
Nothing about a missing grid file looks like an error from inside the process. PROJ enumerates the operations available between two datums, discards the ones whose requirements it cannot satisfy, and returns the best of what remains. From its perspective the request succeeded: a transformation was asked for, a transformation was supplied. The returned coordinates are well-formed, finite, in the right units, and plausible.
What changes is accuracy, and accuracy is not visible in a single coordinate. It is only visible against an external reference — which is exactly what a CI environment lacks by default. The result is an environment-dependent pipeline whose difference from the correct one is a constant offset, and a constant offset is invisible to every per-feature check in a spatial suite.
Detection reference
| Signal | What it tells you | Cost |
|---|---|---|
TransformerGroup(...).unavailable_operations |
PROJ knows a better operation and cannot use it | One lookup |
pyproj.datadir.get_data_dir() |
Where PROJ is actually looking | Free |
| Presence of the named grid file on that path | Whether the specific file exists | One stat call |
pyproj.show_versions() output |
Library and data package versions in force | Free |
Selected operation’s .accuracy |
Whether the chosen path meets the budget | One lookup |
| A transformed monument coordinate | Whether the result is right, not just available | One transform |
Step-by-step implementation
The guard below targets pyproj 3.6+ and runs before any test collects.
Step 1 — Ask PROJ what it could not use
unavailable_operations is the direct answer, and it names the missing resource rather than merely reporting that something is absent.
from pyproj.transformer import TransformerGroup
def missing_operations(src: str, dst: str) -> list[str]:
"""Operations PROJ knows about but cannot perform in this environment."""
group = TransformerGroup(src, dst)
return [op.name for op in group.unavailable_operations]
An empty list means PROJ has everything it knows how to use for that CRS pair. A non-empty one means a better transformation exists and this environment cannot reach it — which is precisely the condition that produces a silent metre.
Step 2 — Confirm where PROJ is looking
A grid package installed somewhere PROJ does not search is indistinguishable from one that is not installed. Reporting the search path in the failure message removes an entire round of investigation.
import pyproj
from pathlib import Path
def data_dir_report() -> str:
d = Path(pyproj.datadir.get_data_dir())
return f"PROJ_DATA={d} exists={d.exists()} entries={len(list(d.glob('*'))) if d.exists() else 0}"
Step 3 — Turn it into a session guard
A guard runs before collection, so a mis-provisioned environment produces one clear message instead of a suite full of geometry failures that are all downstream of it.
# conftest.py
import pytest
REQUIRED_PAIRS = [("EPSG:4326", "EPSG:27700"), ("EPSG:4326", "EPSG:2154")]
@pytest.fixture(scope="session", autouse=True)
def proj_environment_guard():
problems = []
for src, dst in REQUIRED_PAIRS:
missing = missing_operations(src, dst)
if missing:
problems.append(f"{src}->{dst}: unavailable {missing}")
if problems:
pytest.exit(
"PROJ is missing datum grids required by this pipeline:\n "
+ "\n ".join(problems)
+ f"\n{data_dir_report()}",
returncode=3,
)
Using a distinct return code matters: it lets the CI job report an environment fault rather than a validation failure, which is the difference between an engineer looking at the runtime and an engineer looking at geometry.
Step 4 — Pin the package version, not just its presence
Two data packages can both contain a grid of the same name with different contents, so presence is a weaker guarantee than it appears. Record the version and assert it.
def test_proj_data_version_is_pinned():
import pyproj
# The version string is recorded in the image build and asserted here.
assert pyproj.proj_version_str.startswith("9."), pyproj.proj_version_str
# A behavioural check catches the case where versions are right and files are not.
tf = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True)
east, north = tf.transform(-1.542324, 53.797416)
assert abs(east - 429157.19) < 0.05 and abs(north - 434005.51) < 0.05
Verify the fix
Provoke the failure deliberately once, to confirm the guard actually fires:
PROJ_DATA=/nonexistent pytest -q tests/ 2>&1 | head -5
The session should exit immediately with the guard’s message naming the unavailable operations and the search path. If instead the suite runs and the geometry tests fail, the guard is not wired as an autouse session fixture and is proving nothing.
Where the grid package should come from
The most durable arrangement is to install the data package in the container image at a pinned version, alongside the PROJ library, and to set the search path explicitly rather than relying on a default. That places the grid on the same footing as the engine itself, which is the point: they version independently, and a pipeline that pins one and floats the other has pinned nothing that matters. The mechanics belong to containerized GIS test runtimes.
Two alternatives are worth knowing and neither is suitable for a gate. PROJ can fetch grids from a network endpoint on demand, which is convenient for exploratory work and introduces a network dependency plus a cache into what is supposed to be a deterministic check. And some package managers ship a minimal grid set by default, adding the full set only as a separate package — which is the specific reason so many pipelines are correct on a developer’s conda environment and wrong on a slim CI image.
Reporting it as what it is
The last piece is the report. An environment fault surfaced through the same channel as a validation failure sends an engineer to look at geometry, and that investigation is entirely wasted before it starts. Three details make the distinction visible without anyone reading a log.
Use a distinct exit code for the guard, separate from the one pytest returns for a failing assertion, so the CI job can label the run as an infrastructure result. Emit a one-line job summary naming the missing operation and the search path, so the failure is legible from the pipeline list. And route the notification to whoever owns the runtime image rather than to the author of the change, because the change did not cause it and the author cannot fix it.
That last point is the one that decides whether the check stays. A guard that reliably blocks merges for a problem the merging engineer cannot address will be removed within a month, however correct it is. Pointing it at the right owner makes it a useful signal rather than an obstacle, and the fix — a version bump in the image definition — is usually a two-line change once the right person sees it.
Failure modes and edge cases
- Checking presence rather than availability. A grid file on disk that PROJ cannot read — wrong permissions, wrong path, wrong format version — passes a file-existence check and still produces the fallback. Ask PROJ what it can use rather than asking the filesystem what exists.
- A guard that runs as a test. Written as an ordinary test it executes somewhere in the middle of the suite, after dozens of geometry failures have already been reported. Wire it as an autouse session fixture so it runs first.
- Only guarding the CRS pairs you remembered. Derive the required pairs from the pipeline’s configuration rather than hard-coding a list, or a new transformation added later escapes the guard entirely.
- Assuming the fallback is always worse. For some datum pairs the parameter-based operation is the published, official transformation and the grid is a refinement. Assert the accuracy budget rather than assuming grid-or-nothing.
- Ignoring the vertical component. Height transformations use separate geoid models with the same failure mode and a much larger magnitude — tens of metres rather than one. Guard them separately if the pipeline carries elevation.
- A network-fetch fallback masking the problem. If PROJ is configured to fetch grids over the network, the guard passes on a runner with connectivity and the pipeline becomes dependent on an external service. Disable network fetching in CI so the absence is loud.
Conclusion
A missing datum grid is the clearest example of an environment fault that presents as a data problem, and it is trivially detectable. Asking PROJ which operations it could not use, reporting where it searched, running the check as a session guard with its own exit code, and anchoring it with one externally-published coordinate converts hours of confused geometry investigation into a single message at the start of the run — the availability half of coordinate reference system testing.
Related
- Coordinate Reference System Testing — the parent layer and where this guard sits within it.
- Testing Datum Shifts with pyproj Transformer — pinning the operation whose availability this guard checks.
- Pinning GDAL/PROJ Versions in Docker Test Images — installing the data package so the guard has nothing to report.
- Asserting CRS Round-Trip Accuracy in pytest — the numerical check whose systematic-offset signature this guard explains.