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.
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
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
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
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.Related
- Choosing a Transformation API: PROJ, pyproj, or Manual Grids — the parent decision framing this trade-off.
- When to use manual grid interpolation over pyproj — the sibling case for bypassing PROJ entirely.
- Understanding NTv2 grid shift files in Python — what the named grid in a pipeline actually contains.
- Batch Transformation & Automation for Cadastral Coordinate Pipelines — the pipeline-level reference on reproducible, auditable batches.