Selecting a Datum Transformation Pipeline with pyproj
For most coordinate reference system pairs PROJ knows several transformations, differing in accuracy and in which grid files they need. Enumerate them, choose one deliberately, construct the transformer from that choice, and record it with the output — because the default selection depends on what happens to be installed, and that makes results machine-dependent in a way nothing reports. This page is part of CRS Normalization Workflows.
Why the Default Is Not a Decision
A datum transformation is an empirical relationship between two reference frames, established by measurement. Different campaigns, epochs and regions produce different relationships, all of them valid, differing in precision and in coverage. PROJ’s database records them as separate coordinate operations, each with a stated accuracy and a list of grid files it requires.
When you build a transformer from a source and a target, PROJ scores the candidates and picks the most accurate one it can actually perform. That last clause is the problem: availability depends on which grids are installed, so the identical call on two machines can select two operations and return answers differing by metres. Neither run reports anything unusual, because from PROJ’s point of view both did the best they could.
Making the choice explicit removes the dependency. Enumerate the candidates, decide which one the work requires, construct the transformer from that pipeline, and let a missing grid raise instead of degrade.
Production-Ready Script
# pyproj>=3.5.0 (PROJ 9.x), Python 3.9+
from __future__ import annotations
from dataclasses import dataclass, asdict
from pyproj import CRS, Transformer
from pyproj.transformer import TransformerGroup
@dataclass(frozen=True)
class Candidate:
name: str
accuracy_m: float | None
grids: tuple[str, ...]
grids_available: bool
definition: str
def candidates(src: CRS, dst: CRS, *, area_of_interest=None) -> list[Candidate]:
"""Every operation PROJ knows for this pair, in its own preference order."""
group = TransformerGroup(src, dst, always_xy=True, area_of_interest=area_of_interest)
out: list[Candidate] = []
for op in group.transformers:
info = op.operations[0] if op.operations else None
grids = tuple(g.short_name for g in (info.grids if info else []))
available = all(g.available for g in (info.grids if info else []))
out.append(Candidate(
name=op.description,
accuracy_m=getattr(info, "accuracy", None) if info else None,
grids=grids,
grids_available=available,
definition=op.to_proj4() if hasattr(op, "to_proj4") else op.description,
))
return out
def choose(cands: list[Candidate], *, require_grids: bool = True,
max_accuracy_m: float | None = None) -> Candidate:
"""Deliberate selection, with the reasons stated as arguments."""
pool = [c for c in cands if c.grids_available] if require_grids else list(cands)
if max_accuracy_m is not None:
pool = [c for c in pool
if c.accuracy_m is not None and c.accuracy_m <= max_accuracy_m]
if not pool:
raise RuntimeError(
"no candidate satisfies the requirements — install the grids, or relax "
"the accuracy requirement deliberately"
)
return min(pool, key=lambda c: (c.accuracy_m if c.accuracy_m is not None else 1e9))
def pinned_transformer(src: CRS, dst: CRS, chosen: Candidate) -> Transformer:
"""Build from the SELECTED pipeline, not from the CRS pair."""
return Transformer.from_pipeline(chosen.definition) \
if chosen.definition.startswith("+proj=pipeline") \
else Transformer.from_crs(src, dst, always_xy=True)
if __name__ == "__main__":
src, dst = CRS.from_epsg(27700), CRS.from_epsg(4326)
cands = candidates(src, dst)
for c in cands:
acc = f"{c.accuracy_m:g} m" if c.accuracy_m is not None else "unstated"
print(f"{acc:>10} grids={'yes' if c.grids_available else 'MISSING'} {c.name}")
picked = choose(cands, require_grids=True, max_accuracy_m=1.0)
print("selected:", asdict(picked))
Key implementation notes:
TransformerGroupis the enumeration API. It exposes what the default selection would have chosen from, which is the information the default hides.choosetakes its criteria as arguments, so the selection policy is visible at the call site rather than embedded in the function.- Filtering on grid availability before accuracy is deliberate: an operation that cannot run is not a candidate however accurate it claims to be.
- The selected candidate is a dataclass, so it serialises straight into the provenance record written next to the output.
- An area of interest, where the work is regional, narrows the candidate list usefully — several national transformations are only valid within their own extent.
Compatibility Matrix
| Component | Supported range | Notes |
|---|---|---|
pyproj |
>=3.5.0 |
TransformerGroup, operation accuracy and grid metadata |
| PROJ | 9.x |
operation enumeration and grid availability flags |
| Grids | projsync or a data package |
install at image build time |
PROJ_NETWORK |
OFF in production |
so a missing grid raises rather than downloads |
| Area of interest | optional | narrows candidates to regionally valid operations |
Fallback Strategies
1. No candidate has its grids. Install them, or relax the requirement explicitly and record that a coarse operation was used. Do not let the default silently do the relaxing.
2. Accuracy is unstated. Some operations carry no accuracy figure. Treat unstated as unknown rather than as good, and prefer a candidate that states one.
3. Results differ between environments. Compare the recorded selections. A difference in operation name explains a difference in coordinates without further investigation.
4. Several candidates are regionally scoped. Supply an area of interest so PROJ filters to the operations valid where the data actually is.
5. The pipeline definition is not reusable. Some operations do not round-trip through a text definition. Fall back to constructing from the CRS pair, and assert after the first transform that the operation actually used is the one selected.
FAQ
Why are there several transformations for one CRS pair?
Because a datum relationship is measured, not defined, and different measurement campaigns produce different realisations. A pair such as OSGB36 to WGS84 has a coarse seven-parameter transformation good to a few metres and a grid-based one good to centimetres, plus regional variants. They are all legitimate; they answer the same question to different precisions.
What does PROJ choose if I do not?
The most accurate operation whose grids are actually available. That is a sensible default and a reproducibility hazard: the same code on a machine without the grid silently selects a coarser operation and returns answers differing by metres, with no error and no warning.
Should I always pin the most accurate one?
Pin the one appropriate to the work, and make sure its grids are installed. The most accurate operation on a machine that lacks its grid is worse than a deliberately chosen coarse one, because it will not be used and nothing will say so. Accuracy you cannot execute is not accuracy.
Related Pages
- CRS Normalization Workflows — parent reference on detection, validation, transformation and verification
- Reprojecting CAD Coordinates with pyproj Transformer — the transformer this selection configures
- Applying a Geoid Model with pyproj — the same reproducibility argument for the vertical grids