Applying a Geoid Model with pyproj

A geoid model is a raster of separation values that PROJ interpolates to convert between ellipsoidal and orthometric heights, and getting a reproducible result means controlling which one is installed rather than trusting the default. Fetch the grid at image build time, set PROJ_NETWORK=OFF at run time, and assert the intended grid was actually used. This page sits under Vertical Datums and Height Systems and supplies the grid that the ellipsoidal-to-orthometric conversion depends on.

How PROJ Resolves a Grid

PROJ does not have one geoid model; it has an operation search. Given a source and a target coordinate reference system it enumerates the transformations it knows about, scores them by accuracy and availability, and picks the best one it can actually perform. Availability is the operative word: an operation requiring a grid that is not installed is either skipped in favour of a less accurate one, or — with network access enabled — triggers a download.

How availability decides which operation runs A branch on whether the grid a candidate operation needs is installed. With the grid present PROJ selects the accurate operation and interpolates it. Without it, and with network access enabled, PROJ may fetch it; with the network disabled it falls back to a coarser model and returns a result anyway. Only the last branch is silent, and it is the default in a container nobody configured. Is the national grid installed? Accurate operation centimetre level yes Fetched at run time depends on a service no, network on Coarse fallback silent, metre level no, network off

This is a sensible design and a reproducibility hazard. The same code on two machines can select two different operations, return answers differing by metres, and report success in both cases. Nothing in the API forces you to notice.

The resolution has three parts. Install the grids deliberately, disable the network at run time so absence is an error, and inspect the operation the transformer selected rather than assuming.

Production-Ready Script

# pyproj>=3.5.0 (PROJ 9.x), Python 3.9+
from __future__ import annotations

import os
from dataclasses import dataclass, asdict

import pyproj
from pyproj import CRS, Transformer


@dataclass(frozen=True)
class GridProvenance:
    """What actually performed the transformation — written next to the output."""
    proj_version: str
    pyproj_version: str
    operation: str
    grids: tuple[str, ...]
    network_enabled: bool


def assert_offline() -> None:
    """A production run must not depend on the PROJ CDN."""
    if pyproj.network.is_network_enabled():
        raise RuntimeError(
            "PROJ network access is enabled — set PROJ_NETWORK=OFF so a missing "
            "grid fails loudly instead of silently falling back"
        )


def transformer_with_provenance(
    src: CRS, dst: CRS, probe: tuple[float, float, float]
) -> tuple[Transformer, GridProvenance]:
    """Build a transformer and record exactly which operation and grids it used."""
    t = Transformer.from_crs(src, dst, always_xy=True)
    t.transform(*probe)                     # resolve the operation
    op = t.get_last_used_operation()

    missing = [g.short_name for g in op.grids if not g.available]
    if missing:
        raise RuntimeError(f"grid(s) not installed: {', '.join(missing)}")

    prov = GridProvenance(
        proj_version=pyproj.proj_version_str,
        pyproj_version=pyproj.__version__,
        operation=op.name,
        grids=tuple(g.short_name for g in op.grids),
        network_enabled=pyproj.network.is_network_enabled(),
    )
    return t, prov


if __name__ == "__main__":
    assert_offline()
    src = CRS.from_epsg(4937)                        # ETRS89, ellipsoidal height
    dst = CRS.from_string("EPSG:27700+5701")         # BNG + ODN
    t, prov = transformer_with_provenance(src, dst, probe=(-1.5, 53.8, 100.0))
    print(asdict(prov))
The provenance record written next to the output The five facts that make a transformed dataset reproducible: the PROJ and pyproj versions, the operation actually selected, the grids it used, and whether network fetching was enabled. Two datasets that disagree are then resolved by comparing two records rather than by re-deriving what each environment happened to have installed. proj_version 9.3.1 grid behaviour changes between releases pyproj_version 3.6.1 operation OSGB36 to ETRS89 (2) which of several candidates ran grids uk_os_OSGM15_GB.tif the file that supplied the separation network false a missing grid raised rather than downloaded A difference between two runs is a diff between two of these, not an investigation.

Key implementation notes:

  • assert_offline is called before anything else. A run that can reach the CDN is a run whose results depend on a service, and the point of the exercise is to remove that dependency.
  • The probe transform exists to force operation resolution. PROJ selects lazily, so an unused transformer has no operation to inspect.
  • GridProvenance is written next to the output. Six months later, a discrepancy between two datasets is answerable by comparing two provenance records instead of guessing.
  • The grid availability check raises rather than warns, because a warning in a batch log is a warning nobody read.

Compatibility Matrix

Component Supported range Notes
pyproj >=3.5.0 pyproj.network and operation grid reporting
PROJ 9.x grid availability flags on the operation object
Grid source projsync or proj-data pick one per image; mixing obscures what is installed
PROJ_NETWORK OFF in production ON only for a deliberate build-time fetch
PROJ_DATA explicit path set it rather than relying on the discovered default

Fallback Strategies

1. Grid missing in the image. The build stage did not fetch it, or fetched a different bounding box. Widen the projsync bounding box to cover the project extent with margin, and assert at start-up rather than at first transform.

Where the grid enters the container lifecycle Four stages. The grid is fetched during the image build, where the network is available and the result becomes part of the artefact. At run time network fetching is disabled so absence is an error. Start-up asserts the grid is present, and each run records which one was used. Fetching at run time instead makes every transformation depend on an external service. Build: fetch the grid projsync by bounding box 1 network available here, and only here Run: network off PROJ_NETWORK=OFF 2 absence becomes an error Start-up: assert grid available 3 fail before accepting work Per run: record operation and grids 4 reproducible, not merely repeatable

2. Two images disagree. Compare the provenance records. A difference in operation or grids explains a difference in results without any further investigation, which is the whole reason for recording them.

3. The build machine has no network. Use a data package installed from the same artefact repository as the rest of the dependencies. It is larger, and it is reproducible without any network at build time.

4. A grid update changes results. This is expected — grids are revised — and it is why the version is recorded. Treat a grid update as a change requiring re-validation against benchmarks, not as an invisible dependency bump.

5. PROJ_DATA pointing somewhere unexpected. A conda environment, a system PROJ and a wheel-bundled PROJ can all be present at once, and the discovered data directory may not be the one you populated. Set PROJ_DATA explicitly and print pyproj.datadir.get_data_dir() at start-up.

FAQ

What is the difference between projsync and a data package?

projsync fetches individual grids from the PROJ CDN into the local data directory on demand or by bounding box. A data package such as proj-data is a single archive of everything, installed by a package manager. projsync gives a small image; the package gives a reproducible one without network access at build time. Both are fine; mixing them is what causes confusion about which grid is actually present.

Why does the same code give different answers on two machines?

Almost always a different grid. PROJ resolves the best available operation, and “best available” depends on what is installed. One machine with a national grid and one without produce answers differing by decimetres to metres, with no code difference. Pin the grid set and assert it at start-up.

Should PROJ_NETWORK be on in production?

No. Leaving it on makes every transformation potentially dependent on a network service, and the failure mode is not an error — it is a fallback to a coarser model. Fetch at build time, turn it off at run time, and let a missing grid fail loudly.