Choosing and Pinning a PROJ Transformation Pipeline

Control which PROJ operation a PyQGIS transform actually uses: how selection silently degrades when grids are missing, reading…

TL;DR: PROJ silently selects the most accurate transformation whose grid files are actually installed, so the same script can produce different coordinates on two machines — log the operation that was used, and pin it explicitly wherever the output is a record rather than a picture. This page is part of the coordinate transformations and CRS handling guide.

Complete Runnable Code

python
"""Inspect, log and pin the operation behind a coordinate transform."""
from qgis.core import (QgsCoordinateReferenceSystem, QgsCoordinateTransform,
                       QgsCoordinateTransformContext, QgsDatumTransform)


def describe_operation(source: str, target: str,
                       context: QgsCoordinateTransformContext | None = None) -> dict:
    """Report which PROJ operation a transform between two CRS would actually use."""
    src = QgsCoordinateReferenceSystem(source)
    dst = QgsCoordinateReferenceSystem(target)
    if not src.isValid() or not dst.isValid():
        raise ValueError("invalid CRS: %r -> %r" % (source, target))

    transform = QgsCoordinateTransform(src, dst, context or QgsCoordinateTransformContext())
    if not transform.isValid():
        raise RuntimeError("no usable operation between %s and %s" % (source, target))

    details = transform.instantiatedCoordinateOperationDetails()
    return {"proj": details.proj,
            "accuracy": details.accuracy,
            "grids_missing": [g.shortName for g in details.grids if not g.isAvailable]}


def available_operations(source: str, target: str) -> list[dict]:
    """Every candidate operation PROJ knows about, in its own ranking order."""
    src = QgsCoordinateReferenceSystem(source)
    dst = QgsCoordinateReferenceSystem(target)
    return [{"proj": op.proj, "accuracy": op.accuracy,
             "grids_missing": [g.shortName for g in op.grids if not g.isAvailable]}
            for op in QgsDatumTransform.operations(src, dst)]


def pinned_context(source: str, target: str, proj_string: str
                   ) -> QgsCoordinateTransformContext:
    """A context that forces one specific operation for this CRS pair."""
    context = QgsCoordinateTransformContext()
    context.addCoordinateOperation(QgsCoordinateReferenceSystem(source),
                                   QgsCoordinateReferenceSystem(target),
                                   proj_string)
    return context

Called on any pair, describe_operation tells you what a run would actually do — which is information no exception will ever give you:

python
print(describe_operation("EPSG:4326", "EPSG:27700"))
How PROJ picks an operation when you do not A four-step selection: PROJ lists candidate operations for the CRS pair, ranks them by stated accuracy, discards those whose grid files are missing, and uses the best of what remains. candidates for the CRS pair ranked by accuracy available only grids present best remaining used silently listed sorted filtered

Architecture Breakdown

Selection is silent by design

PROJ knows several ways to get between most datum pairs: a high-accuracy grid shift, a seven-parameter Helmert, a three-parameter approximation. It ranks them by stated accuracy, removes any whose grid files are not present on this machine, and uses the best of what is left.

Nothing about that process raises or warns. A transform built on a machine without the NTv2 grid is perfectly valid; it is simply less accurate, by an amount that depends on where in the country you are.

What changes when the grid is missing A grid comparing a transform with its datum-shift grid installed against the same transform without it, across accuracy, reproducibility and whether anything is reported. grid installed grid missing accuracy centimetres metres to hundreds operation used the NTv2 grid a Helmert approximation reproducible yes depends on the machine reported n/a nothing at all

The last row of that table is the reason this page exists. Accuracy loss you can measure and decide about; accuracy loss you are not told about propagates into whatever the output feeds.

instantiatedCoordinateOperationDetails() is the answer

The method reports the operation the transform actually instantiated: the PROJ pipeline string, the claimed accuracy, and the grids it wanted with a flag for each saying whether it found them. Logging that string alongside every batch run turns an invisible property into an auditable one.

It costs one call at start-up and it is the single highest-value line of diagnostic code in any coordinate pipeline.

Pinning with addCoordinateOperation()

Registering an operation on the context forces it for that CRS pair, for every transform built with that context. If the grid it needs is absent, the transform is invalid and the run fails — which is the point. A loud failure is better than a quiet approximation whenever the coordinate ends up in a record somebody relies on.

Deciding Whether to Pin

Pinning trades portability for reproducibility, and which of the two you want depends entirely on what the coordinate is for.

Pin the pipeline, or let PROJ choose? A decision tree over three uses of a coordinate: display work can let PROJ choose, engineering and legal work should pin the operation, and cross-machine pipelines should pin and verify. What happens to this coordinate? it is displayed let PROJ choose portable it is a record pin the operation fail if absent it crosses machines pin and verify log the pipeline

For display work — a map, a preview, a visual check — letting PROJ choose is right. The output is looked at rather than measured, portability across machines matters more than reproducibility, and a metre either way changes nothing.

For anything that becomes a record — a cadastral boundary, a monitoring point, a coordinate quoted in a report — pin it. Somebody will eventually ask which transformation produced a number, and “whichever PROJ preferred on the machine that ran it” is not an answer.

The third case is a pipeline that runs on several machines: developer laptop, CI runner, production server. Pinning makes them agree; logging the operation proves they did.

Making Grids Present in a Container

A container is the easiest place to guarantee grid availability, because the image is the environment:

dockerfile
FROM qgis/qgis:release-3_34
RUN apt-get update && apt-get install -y --no-install-recommends proj-data \
    && rm -rf /var/lib/apt/lists/*

proj-data carries the transformation grids that proj-bin alone does not. Without it, a container that looks identical to a workstation produces different coordinates, and the difference shows up only when somebody compares outputs.

For grids not in the distribution package, PROJ’s network capability can fetch them on demand — but in a scheduled job that turns a coordinate transformation into a network dependency, which is usually the wrong trade. Baking them into the image keeps the run self-contained.

Comparing Two Environments Before You Trust Them

Setting up a pipeline that runs in more than one place is the right moment to check that the places agree, and the check is short enough to run as part of provisioning.

Run describe_operation for every CRS pair the pipeline uses, on each environment, and compare the PROJ strings. Identical strings mean the environments will produce identical coordinates. Different strings mean they will not, and the difference in reported accuracy tells you by roughly how much.

Doing this once, at set-up, is worth more than any amount of investigation afterwards. The alternative — noticing a discrepancy in output months later and working backwards — is one of the harder debugging exercises in geospatial work, precisely because nothing in the pipeline logged which transformation it used.

Production Best Practices

  • Log the operation on every run. One line, and it answers the question nobody can reconstruct later.
  • Pin the pipeline for anything auditable, and let it fail when the grid is absent.
  • Install proj-data in containers, or accept that the container is less accurate than the desktop.
  • Compare operations across environments as part of setting a pipeline up, not after a discrepancy.
  • Do not use PROJ network access in scheduled jobs without a deliberate decision about the dependency.
  • Record the PROJ version too. Operation rankings change between releases, and that is a real source of drift.

Frequently Asked Questions

How do I know which grid a transformation wants?

instantiatedCoordinateOperationDetails().grids lists them, each with an availability flag and a download URL. Iterating candidate operations with QgsDatumTransform.operations() shows the same information for every alternative, which is how you find out what accuracy you are missing rather than merely that you are missing something.

Does the transform context come from the project?

Inside QGIS Desktop, yes — QgsProject.instance().transformContext() carries whatever the user configured, including any operations they pinned in the project properties. In a standalone script there is no project, so the context is empty unless you build one, which is precisely why a desktop-verified script can behave differently on a server.

What accuracy should I expect without grids?

It depends entirely on the datum pair and the location. For a national grid with an official NTv2 shift, the fallback Helmert is typically accurate to within a metre or two but can be much worse at the edges of the area of use. For plate-motion corrections the difference grows with time since the epoch. The only honest answer for your data is to run both and compare.

Can I pin an operation in a QGIS project?

Yes — the project properties dialog lets a user select the transformation for a CRS pair, and the choice is stored in the project and applied through its transform context. That is the right place for a desktop workflow, and reading the same context in a script is what keeps the two consistent.

Is a pinned pipeline portable?

Only to machines that have what it needs, which is the point. A pinned pipeline that cannot be instantiated makes the run fail with a clear error, so the deployment problem surfaces at deployment rather than in the output.

Should I transform through WGS 84 as an intermediate?

Not deliberately. PROJ will route through an intermediate where that is genuinely the best available operation, and forcing it by transforming twice yourself adds a second approximation and loses the accuracy metadata. Ask for the transformation you actually want and let PROJ compose it.