PROJ Pipeline Strings vs pyproj Transformer API

For a cadastral transformation that may be audited, the question is not whether pyproj can move a coordinate but whether you can prove exactly which operation moved it — which is why this decision sits under Choosing a Transformation API: PROJ, pyproj, or Manual Grids, the guide that in turn belongs to Batch Transformation & Automation for Cadastral Coordinate Pipelines. The two forms in view are the explicit +proj=pipeline string, where you name every step and grid, and Transformer.from_crs, where you name only the source and target CRS and let PROJ search for the best operation. Both call the same C engine; they differ in who chooses the operation — you, in text you can commit and cite, or PROJ, at run time against whatever data is installed.

Explicit pipeline versus searched operation A source coordinate splits into two paths. The upper path, from_pipeline, feeds a fixed text string of named steps straight into the PROJ engine, producing a reproducible result. The lower path, from_crs, first runs an operation search that ranks candidate operations by accuracy and grid availability, then feeds the chosen one into the same engine, producing a result whose operation can change if the installed data changes. Coordinate (lon, lat) from_pipeline: fixed named +step string from_crs: operation search picks best PROJ engine + result same engine, two ways of choosing

Figure — an explicit pipeline feeds fixed steps to the engine; from_crs searches first.

What “Best Operation” Actually Means

When you call Transformer.from_crs("EPSG:4267", "EPSG:4269"), PROJ consults its operation database and returns the candidate operations ranked by accuracy, preferring those whose grids are present on the PROJ_DATA path. The top-ranked one is used. This is genuinely convenient and, for interactive and web GIS, correct. The catch for cadastral work is that the ranking is resolved at run time: install a new PROJ data package, move to a colleague’s machine with a different grid set, or upgrade the library, and “best” can resolve to a different operation — a different grid, or a bare Helmert fallback with metre-level error where a grid should have been used. The coordinate changes, silently, with no line of your code having changed.

An explicit pipeline removes the search entirely. The string

+proj=pipeline +step +proj=hgridshift +grids=ca_nrc_ntv2_0.tif +step +proj=noop

names the grid and the step order outright. PROJ does not rank anything; it runs what you wrote. The string is a plain-text artifact you commit to version control, diff across releases, and cite in a submission — which is precisely the reproducibility the searched form cannot guarantee. The trade is verbosity and the burden of knowing the correct operation yourself, but for a certified deliverable that burden is the job.

Both Forms, Producing the Same Result

The following script builds both transformers for the NAD27→NAD83 case and confirms they agree, then shows how to pin the operation that from_crs would have chosen so it can be recorded. Every transformer uses always_xy=True so coordinates are supplied and returned in longitude, latitude order rather than PROJ’s authority-defined latitude-first order.

from dataclasses import dataclass

import pyproj


@dataclass(frozen=True)
class DualResult:
    searched: tuple[float, float]
    pinned: tuple[float, float]
    agree_within_mm: bool


def transform_both_ways(
    lon_deg: float,
    lat_deg: float,
    pipeline_str: str,
    src_epsg: str = "EPSG:4267",
    dst_epsg: str = "EPSG:4269",
    tolerance_m: float = 0.001,
) -> DualResult:
    """Transform one point with both the searched from_crs operation and an
    explicit pipeline string, and report whether they agree within tolerance.

    Args:
        lon_deg, lat_deg: input coordinate in degrees, +east / +north.
        pipeline_str: explicit +proj=pipeline definition naming the grid step.
        src_epsg, dst_epsg: source and target CRS authority codes.
        tolerance_m: agreement gate, treating one degree as ~111,320 m.
    """
    searched = pyproj.Transformer.from_crs(src_epsg, dst_epsg, always_xy=True)
    pinned = pyproj.Transformer.from_pipeline(pipeline_str)
    lon_s, lat_s = searched.transform(lon_deg, lat_deg)
    lon_p, lat_p = pinned.transform(lon_deg, lat_deg)
    deg_to_m = 111_320.0
    spread_m = max(abs(lon_s - lon_p), abs(lat_s - lat_p)) * deg_to_m
    return DualResult(
        searched=(lon_s, lat_s),
        pinned=(lon_p, lat_p),
        agree_within_mm=spread_m <= tolerance_m,
    )

To make the searched operation itself auditable, capture the operation from_crs selected rather than trusting it to stay constant. TransformerGroup exposes the ranked candidates, and each transformer can emit its own PROJ definition string, which you log alongside the result:

import logging

logger = logging.getLogger("pipeline_vs_api")


def pin_searched_operation(src_epsg: str, dst_epsg: str) -> str:
    """Return the PROJ definition of the operation that from_crs would use, so
    it can be frozen into a pipeline string and recorded for audit."""
    group = pyproj.transformer.TransformerGroup(
        src_epsg, dst_epsg, always_xy=True
    )
    if not group.transformers:
        raise ValueError(f"No operation available for {src_epsg} -> {dst_epsg}")
    best = group.transformers[0]                  # highest-ranked candidate
    definition = best.to_proj4()                  # the concrete steps, as text
    logger.info("pinned_operation %s", definition)
    return definition

Parameter Reference

Name Type Units Range Notes
lon_deg, lat_deg float degrees −180…180 / −90…90 input coordinate, +east / +north
pipeline_str str valid PROJ pipeline the pinned operation as citable text
src_epsg, dst_epsg str authority codes source and target CRS
always_xy bool keep True forces lon/lat order, avoiding axis swaps
tolerance_m float metres 0.001 typical agreement gate between the two forms
agree_within_mm bool True when the forms match within tolerance

Worked Example

pipeline = (
    "+proj=pipeline "
    "+step +proj=hgridshift +grids=ca_nrc_ntv2_0.tif "
    "+step +proj=noop"
)
result = transform_both_ways(-75.6972, 45.4215, pipeline)
print(result.agree_within_mm)
# True  -- the searched and pinned operations land on the same coordinate

Validation Check

Assert agreement before either result is trusted, and fail loudly if the searched form has quietly diverged from the pinned one — the exact symptom of best-operation drift.

assert result.agree_within_mm, (
    "from_crs diverged from the pinned pipeline: an operation-search change "
    "or a missing grid on the PROJ_DATA path is the likely cause"
)

Common Mistakes

Relying on implicit best-operation selection for a certified batch
Convenient for exploration, indefensible for certification. The operation from_crs returns depends on the installed PROJ data at run time, so a grid-package update or a different machine can silently substitute a lower-accuracy operation. Freeze the operation into a +proj=pipeline string — capture it with to_proj4() as shown — and commit that string with the deliverable.
Axis-order surprises without always_xy
PROJ honours each CRS's authority axis order, which for geographic CRSs like EPSG:4267 is latitude, then longitude. Build every transformer with always_xy=True so you consistently pass and receive (lon, lat); omitting it transposes the pair and moves the point by tens of kilometres — an error large enough to catch, but only if you are checking.
Unpinned operations changing across PROJ versions
Two PROJ releases can rank the same candidate operations differently or ship different grids, so an unpinned from_crs result is not stable across upgrades. Record the PROJ version and the pinned pipeline string with every certified output; if you must use from_crs, log its to_proj4() definition so the operation is at least reconstructable after the fact.