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.
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))
Key implementation notes:
assert_offlineis 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.
GridProvenanceis 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.
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.
Related Pages
- Vertical Datums and Height Systems — parent reference on why the separation matters at all
- Converting Ellipsoidal to Orthometric Heights in Python — sibling guide performing the conversion this grid enables
- CRS Normalization Workflows — the horizontal pipeline with the same reproducibility requirements