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.

Same code, two environments, one metre apart Two parallel pipelines. On the developer machine the complete PROJ data package is installed, the high-accuracy grid-based operation is available and selected, and transformed coordinates land within a decimetre of the published truth; the run reports success. On the continuous integration runner a minimal package is installed, the grid file is absent, PROJ falls back to a Helmert operation, and every transformed coordinate lands roughly one metre away in a consistent direction; this run also reports success. Both produce valid geometry and pass every per-feature check. A footer records that the only observable difference is in the coordinate values, which nothing in the suite compares against an external reference. Developer machine — full data package same code grid present high-accuracy operation selected within 0.1 m of truth run reports success CI runner — minimal package same code grid absent Helmert fallback selected ~1 m away same direction, every feature run reports success Both runs are green, both produce valid geometry, and every per-feature check passes in both. The only observable difference is in coordinates that nothing compares against an external reference.

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
Four checks, from weakest to strongest Four checks arranged in increasing strength. Checking that the PROJ data directory exists is the weakest, and still passes when the directory is present but empty. Checking that the specifically named grid file is on disk is stronger, but still passes when the file is a different revision with the same name. Checking that PROJ reports no unavailable operations is stronger again, and still passes when the grid present is not the revision the pipeline was calibrated against. Transforming a published monument coordinate and comparing the result against its externally known value is the strongest, because it tests the outcome rather than any of the inputs, and it misses nothing. 1 · the data directory exists still passes when the directory is empty weakest 2 · the named grid file is on disk still passes when the file is a different revision 3 · no unavailable_operations still passes when the revision is not the calibrated one 4 · a published monument lands where it should tests the outcome, not the inputs — misses nothing strongest Run the cheap ones for a clear message and the last one for the guarantee. Together they are under a second.

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.

Three sources for a grid, one suitable for a gate Three provisioning options compared. Installing the data package into the container image at an explicitly pinned version, with the search path set, is deterministic, works offline, and is marked as the only option suitable for a gate. Fetching grids from a network endpoint on demand introduces both a network dependency and a cache, so two runs of the same pipeline can use different data. Relying on whatever the package manager happened to install is least deterministic of all, because shipping a minimal grid set by default is common and the resulting difference is entirely silent. Pinned package in the image, explicit search path deterministic · offline · version recorded in the run summary use this for a gate Network fetch on demand network dependency · a cache between runs · two runs can differ exploratory work only Whatever the package manager installed a minimal grid set is a common default · the difference is silent the usual cause of this bug

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.