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.

Several operations answer the same question Three coordinate operations for one system pair, with the accuracy each claims and what it needs to run. They are all legitimate — a datum relationship is measured rather than defined, so different campaigns produce different realisations. Which one runs depends on what is installed, which is exactly why it should be chosen rather than inherited. Operation Accuracy Requires Grid-based ~0.1 m a national grid file Seven-parameter ~2 m nothing Three-parameter ~5 m nothing PROJ picks the most accurate it can perform — which depends on the machine.

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))
Enumerate, filter, pick, record Four stages that replace an implicit default with a stated decision. The candidate operations are enumerated, filtered to those whose grids are actually installed, narrowed by an accuracy requirement, and the choice is recorded alongside the output. Filtering on availability before accuracy is deliberate: an operation that cannot run is not a candidate. Enumerate every known operation 1 what the default chose from Filter on availability grids installed 2 accuracy you cannot run is not accuracy Apply the requirement accuracy ceiling 3 stated at the call site Record the selection name, accuracy, grids 4 a diff, not an investigation

Key implementation notes:

  • TransformerGroup is the enumeration API. It exposes what the default selection would have chosen from, which is the information the default hides.
  • choose takes 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.

How two machines produce different coordinates from one script The same call on two hosts. One has the national grid installed and selects the accurate operation; the other does not and selects a coarser one. Both return results, neither reports anything unusual, and the coordinates differ by metres. The only difference is what was in the image. the same script host with grid host without from_crs(src, dst) grid-based, ~0.1 m from_crs(src, dst) seven-parameter, ~2 m

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.