How to Reproject Between Datums Correctly (and Why the Grid File Matters)

On this page

Problem statement

The same reprojection, run twice on the same machine, gives different answers:

import pyproj
from pyproj import Transformer

LEEDS_STATION = (-1.5474, 53.7952)                 # longitude, latitude (WGS 84)
for network in (False, True):
    pyproj.network.set_network_enabled(network)
    t = Transformer.from_crs(4326, 27700, always_xy=True)
    x, y = t.transform(*LEEDS_STATION)
    print(f"network {'on ' if network else 'off'}  {x:,.3f}  {y:,.3f}   "
          f"{t.get_last_used_operation().description[:34]}")
pyproj.network.set_network_enabled(False)
network off  429,911.504  433,304.980   Inverse of OSGB36 to WGS 84 (6) + 
network on   429,911.814  433,303.211   Inverse of OSGB36 to WGS 84 (9) + 

Nearly two metres apart. Same input, same EPSG codes, same library β€” a different transformation pipeline, because with the network on PROJ could fetch the OSTN15 grid file and with it off it could not.

For a map at 1:50,000 that is invisible. For a cadastral boundary, a utility asset, or anything with legal consequence, it is the difference between agreeing with the source data and not. And nothing warns you: both runs return a plausible float.

Getting datum transformations right means knowing that there is usually more than one way to do it, choosing deliberately β€” and, as the Department for Transport's own bus-stop data shows below, knowing which one your data was made with.

Quick answer

How far four WGS 84 to OSGB36 pipelines put West Yorkshire’s NaPTAN stops from their own published British National Grid coordinates. distance from the publisher’s own eastings and northingsHelmert (6), stated 2 m0.001 mgrid OSTN15, stated 1 m1.70 mHelmert (8), stated 3 m6.05 mballpark (no shift)103 m0110 mThe most accurate pipeline is not the one this data was made with spatialworkflow.io
The same pair of CRS, three available methods, three very different accuracies.
from pyproj.transformer import TransformerGroup      # not importable from pyproj itself

group = TransformerGroup(4326, 27700, always_xy=True)
for t in group.transformers[:3]:
    print(f"{t.accuracy:>5} m  {t.description[:60]}")
for op in group.unavailable_operations:
    print(f"missing  {op.name[:44]} β€” needs {[g.short_name for g in op.grids]}")
  2.0 m  Inverse of OSGB36 to WGS 84 (6) + British National Grid (wit
 21.0 m  Inverse of OSGB36 to WGS 84 (1) + British National Grid (wit
 10.0 m  Inverse of OSGB36 to WGS 84 (2) + British National Grid (wit
missing  Inverse of OSGB36 to WGS 84 (9) + British Na β€” needs ['uk_os_OSTN15_NTv2_OSGBtoETRS.tif']
Symptom Cause Fix
results differ between machines different grids available install the grids, set PROJ_NETWORK=OFF
results differ between runs grids fetched on demand PROJ_NETWORK=OFF and pin the data
description says "Ballpark" no datum transformation used at all install the grid; expect ~100 m error
agrees with neither colleague they used a different Helmert or the grid compare against the source's own projected coordinates
out by tens of metres in the USA NAD27 without the NADCON grids install the grid
pyproj sync --file uk_os_OSTN15_NTv2_OSGBtoETRS.tif     # one grid, ~15 MB
conda install -c conda-forge proj-data                   # every grid, several hundred MB

Log transformer.get_last_used_operation().description with any coordinates you publish. transformer.description on its own is not enough, as step 1 shows.

What the transformation does, grids, explicit pipelines

1. Find out what your transformation actually does

t = Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True)
print("before:", t.description)
t.transform(*LEEDS_STATION)
print("after: ", t.description)
used = t.get_last_used_operation()
print("used:  ", used.description)
print(f"accuracy {used.accuracy} m")
before: unavailable until proj_trans is called
after:  unavailable until proj_trans is called
used:   Inverse of OSGB36 to WGS 84 (6) + British National Grid (with axis order normalized for visualization)
accuracy 2.0 m

from_crs builds a transformer that can choose between several candidate pipelines, point by point, so it has no single description to give β€” it says so, literally, and keeps saying so after it has run. get_last_used_operation() names the one that actually transformed your coordinates. That is the string to log.

With the network off and no grid installed, this pyproj wheel picked OSGB36 to WGS 84 (6) β€” a seven-parameter Helmert transformation with a stated accuracy of 2 m. It did not fall back to "Ballpark"; there were seven proper transformations to choose from before that.

"Ballpark" is PROJ telling you it gave up. It means no datum transformation was used at all: the two datums are treated as if they coincided. It only appears when nothing better is available β€” allow_ballpark=False makes that an error β€” and its accuracy of -1.0 means unknown, which in practice means large. Example 2 measures how large.

2. Understand what a datum shift is

Two ellipsoids positioned differently, giving the same place different coordinates. a datum is an ellipsoid and where it sitsWGS 84 β€” geocentricfits the whole earthOSGB36 β€” fitted to Britainsame place, different coordsThe offset reaches about 120 m in Britain spatialworkflow.io
Two datums are two different ellipsoids, positioned differently. The same place has different coordinates in each.

A geodetic datum is an ellipsoid plus its position and orientation relative to the earth. Different datums use different ellipsoids, fitted to different regions in different eras:

Datum Ellipsoid Fitted to Offset from WGS 84
WGS 84 WGS 84 the whole earth, geocentric β€”
ETRS89 GRS80 Europe, fixed to the plate ~0 in 1989, ~0.9 m by 2026
OSGB36 Airy 1830 Britain, 19th century up to ~120 m
NAD27 Clarke 1866 North America, 1927 tens of metres, more in Alaska
NAD83 GRS80 North America, 1983 ~1–2 m
GDA94 GRS80 Australia, 1994 ~1.8 m by 2020

So converting between datums moves the point β€” it is a correction, not a rounding. A transformation that ignores it is wrong by the offset.

from pyproj import Geod

geod = Geod(ellps="WGS84")
to_bng = Transformer.from_crs(4326, 27700, always_xy=True)
to_wgs = Transformer.from_crs(27700, 4326, always_xy=True)
x, y = to_bng.transform(*LEEDS_STATION)
back = to_wgs.transform(x, y)
_, _, err = geod.inv(*LEEDS_STATION, *back)
print(f"round trip error: {err * 1000:.1f} mm")
round trip error: 0.9 mm

A clean round trip is evidence the same pipeline ran both ways. It is not evidence that the pipeline is right β€” Example 2 shows the ballpark transformation round-tripping just as cleanly.

3. Make sure the grid files are available

Grid-based transformations model the datum difference as a field that varies with position, stored as a raster. They are more accurate than a single 7-parameter formula because the difference genuinely varies: the nineteenth-century survey behind OSGB36 has its own local distortions.

from pathlib import Path

data_dir = Path(pyproj.datadir.get_data_dir())
user_dir = Path(pyproj.datadir.get_user_data_dir())
print(f"pyproj {pyproj.__version__}, PROJ {pyproj.proj_version_str}")
def tidy(path):                          # shorten machine-specific prefixes for display
    parts = path.parts
    if "site-packages" in parts:
        return ".../" + "/".join(parts[parts.index("site-packages"):])
    return str(path).replace(str(Path.home()), "~")

print(f"data dir   {tidy(data_dir)}")
print(f"grids      {len(list(data_dir.glob('*.tif')))} .tif files bundled")
print(f"user dir   {tidy(user_dir)}  ({', '.join(p.name for p in user_dir.glob('*')) or 'empty'})")
print(f"network    {pyproj.network.is_network_enabled()}")
pyproj 3.8.0, PROJ 9.8.1
data dir   .../site-packages/pyproj/proj_dir/share/proj
grids      0 .tif files bundled
user dir   ~/.local/share/proj  (files.geojson, cache.db)
network    False

A pip install pyproj bundles the PROJ database but no transformation grids at all. With the network on, PROJ fetches the parts of a grid it needs from cdn.proj.org and keeps them in cache.db in the user directory; with it off, that cache is not consulted and the grid is simply unavailable. Install what you need so that neither is the case:

pyproj sync --list-files | grep -i uk_os              # what exists for Britain
pyproj sync --file uk_os_OSTN15_NTv2_OSGBtoETRS.tif   # OSTN15, into the user dir
pyproj sync --area-of-use "United Kingdom"            # every grid for an area
conda install -c conda-forge proj-data                 # the full set

uk_os_OSTN15_NTv2_OSGBtoETRS.tif is OSTN15 in NTv2 form, Ordnance Survey's official transformation between OSGB36 and ETRS89. Without it, WGS 84 to British National Grid falls back to a Helmert.

4. Turn network fetching off for reproducibility

import os
os.environ["PROJ_NETWORK"] = "OFF"      # read when pyproj starts: set it before importing
for enabled in (True, False):
    pyproj.network.set_network_enabled(enabled)     # the same switch, at runtime
    t = Transformer.from_crs(4326, 27700, always_xy=True)
    x, y = t.transform(*LEEDS_STATION)
    print(f"network={str(enabled):<5} {x:>12,.3f} {y:>12,.3f}   {t.get_last_used_operation().description[:34]}")
pyproj.network.set_network_enabled(False)
network=True   429,911.814  433,303.211   Inverse of OSGB36 to WGS 84 (9) + 
network=False  429,911.504  433,304.980   Inverse of OSGB36 to WGS 84 (6) + 

On a machine with no local grid, network access decides which transformation you get. For anything reproducible: install the grids and turn the network off, so a missing grid is a visible degradation rather than a silent dependency on connectivity and on whatever happens to be in the cache.

5. Choose a transformation explicitly when it matters

from_crs picks the most accurate pipeline it can actually run. When the choice must be recorded and stable, look at all of them:

pyproj.network.set_network_enabled(True)
group = TransformerGroup("EPSG:4326", "EPSG:27700", always_xy=True)
print(f"{len(group.transformers)} available, {len(group.unavailable_operations)} unavailable")
for i, t in enumerate(group.transformers):
    print(f"  [{i}] {t.accuracy:>5} m  {t.description[:54]}")
grid = group.transformers[0]
print(grid.definition[:120], "…")
pyproj.network.set_network_enabled(False)
9 available, 0 unavailable
  [0]   1.0 m  Inverse of OSGB36 to WGS 84 (9) + British National Gri
  [1]   2.0 m  Inverse of OSGB36 to WGS 84 (6) + British National Gri
  [2]  21.0 m  Inverse of OSGB36 to WGS 84 (1) + British National Gri
  [3]  10.0 m  Inverse of OSGB36 to WGS 84 (2) + British National Gri
  [4]  21.0 m  Inverse of OSGB36 to WGS 84 (3) + British National Gri
  [5]  18.0 m  Inverse of OSGB36 to WGS 84 (4) + British National Gri
  [6]  35.0 m  Inverse of OSGB36 to WGS 84 (5) + British National Gri
  [7]   3.0 m  Inverse of OSGB36 to WGS 84 (8) + British National Gri
  [8]  -1.0 m  Ballpark geographic offset from WGS 84 to OSGB36 + Bri
proj=pipeline step proj=unitconvert xy_in=deg xy_out=rad step inv proj=hgridshift grids=uk_os_OSTN15_NTv2_OSGBtoETRS.tif …

definition is the full PROJ pipeline behind a transformer. Pinning it with Transformer.from_pipeline(grid.definition) does the same thing on every machine that has the grid, and fails loudly on one that does not.

There are two gentler guards, and they behave very differently when the grid is missing:

try:
    Transformer.from_crs(4326, 27700, always_xy=True, accuracy=1.5)
except pyproj.exceptions.ProjError as err:
    print(f"accuracy=1.5    ProjError: {err}")

best_only = Transformer.from_crs(4326, 27700, always_xy=True, only_best=True)
print(f"only_best=True  {best_only.transform(*LEEDS_STATION)}")
accuracy=1.5    ProjError: Error creating Transformer from CRS.
only_best=True  (inf, inf)

An accuracy floor fails at construction, which is what you want. only_best=True emits a UserWarning naming the missing grid when it is built β€” and then returns infinity for every point. A pipeline that does not stop on warnings carries on with inf coordinates, which is worse than an error.

6. Record the pipeline alongside the results

import json
from datetime import datetime, timezone


def transform_with_provenance(gdf, target_crs, out_json=None):
    src = gdf.crs
    t = Transformer.from_crs(src, target_crs, always_xy=True)
    xs, ys = t.transform(gdf.geometry.x.to_numpy(), gdf.geometry.y.to_numpy())
    used = t.get_last_used_operation()
    out = gdf.set_geometry(gpd.points_from_xy(xs, ys), crs=target_crs)

    record = {
        "source_crs": src.to_string(),
        "target_crs": pyproj.CRS.from_user_input(target_crs).to_string(),
        "pipeline": used.description,
        "accuracy_m": used.accuracy,
        "proj_version": pyproj.proj_version_str,
        "pyproj_version": pyproj.__version__,
        "proj_network": pyproj.network.is_network_enabled(),
        "when": datetime.now(timezone.utc).isoformat(timespec="seconds"),
    }
    if "Ballpark" in used.description:
        print("  ⚠ ballpark transformation β€” error may exceed 100 m")
    if out_json:
        Path(out_json).write_text(json.dumps(record, indent=2))
    return out, record


projected, record = transform_with_provenance(stops, 27700, out_json="transformation.json")
print(record["pipeline"], "β€”", record["accuracy_m"], "m")
Inverse of OSGB36 to WGS 84 (6) + British National Grid (with axis order normalized for visualization) β€” 2.0 m

Six months later, when the numbers differ from a colleague's, the JSON says which pipeline, which PROJ version and whether the network was on. Without it, the question is unanswerable. This is the coordinate half of recording run metadata and data lineage. (It transforms point coordinates directly so that the operation it records is the one that ran; GeoDataFrame.to_crs uses the same machinery but does not hand back its transformer.)

Pre-flight accuracy checks and pinned transformations

What PROJ reports when no real datum transformation was available. "Ballpark" is PROJ telling you it gave upt.transform(lon, lat)op = t.get_last_used_operation()op.descriptionΒ Β Β # "Ballpark geographic offset from WGS 84…"op.accuracyΒ Β Β Β Β Β # -1.0β‘ β‘‘β‘ No datum transformation was used: PROJ treated the two datums ascoinciding. For West Yorkshire’s NaPTAN stops that is about 103 m.β‘‘Accuracy unknown, which in practice means unquantified and large. A realgrid-based transformation reports about 1.0 m here.It raises nothing and returns coordinates that look entirely ordinary spatialworkflow.io
The pre-flight check below refuses to run when it sees either of these two values.

The data used on this page

Every active bus stop, station entrance and ferry point in West Yorkshire from the Department for Transport's NaPTAN. It is useful here for an unusual reason: NaPTAN publishes each stop's position twice, as WGS 84 longitude and latitude and as British National Grid easting and northing, so it records which transformation its publisher used.

import io

import geopandas as gpd
import numpy as np
import pandas as pd
import pyproj
import requests
from pyproj import Transformer
from pyproj.transformer import TransformerGroup

pyproj.network.set_network_enabled(False)          # reproducible unless a step turns it on
# NaPTAN area 450 as saved on 9 October 2026 (the register changes weekly); live:
# naptan.api.dft.gov.uk/v1/access-nodes?dataFormat=csv&atcoAreaCodes=450
naptan = pd.read_csv(io.StringIO(requests.get("https://data.spatialworkflow.io/naptan-area-450-2026-10-09.csv", timeout=300).text),
                     low_memory=False)
naptan = naptan[naptan["Status"] == "active"].reset_index(drop=True)
stops = gpd.GeoDataFrame(naptan[["ATCOCode", "StopType", "Easting", "Northing"]], crs=4326,
                         geometry=gpd.points_from_xy(naptan["Longitude"], naptan["Latitude"]))
print(f"{len(stops):,} stops, each with WGS 84 and British National Grid coordinates")
16,605 stops, each with WGS 84 and British National Grid coordinates

Example 1: a pre-flight check for transformation accuracy

from pyproj.transformer import AreaOfInterest


def check_transformation(src, dst, *, required_accuracy_m=5.0, area=None, verbose=True):
    """Verify a datum transformation is available and accurate enough where the data is."""
    src_crs = pyproj.CRS.from_user_input(src)
    dst_crs = pyproj.CRS.from_user_input(dst)
    group = TransformerGroup(src_crs, dst_crs, always_xy=True, area_of_interest=area)

    if verbose:
        print(f"{src_crs.name} β†’ {dst_crs.name}")

    usable = [t for t in group.transformers
              if t.accuracy is not None and 0 <= t.accuracy <= required_accuracy_m]

    if verbose:
        for t in group.transformers[:4]:
            acc = "unknown" if t.accuracy in (None, -1.0) else f"{t.accuracy} m"
            mark = "βœ“" if t in usable else "βœ—"
            print(f"  {mark} {acc:>9}  {t.description[:56]}")
        for op in group.unavailable_operations[:3]:
            names = [g.short_name for g in op.grids]
            print(f"  βœ— unavailable: {op.name[:44]}")
            print(f"      needs {names} β€” pyproj sync --file {names[0] if names else '?'}")

    if not usable:
        raise RuntimeError(
            f"no transformation from {src_crs.name} to {dst_crs.name} meets "
            f"{required_accuracy_m} m. Best available: {group.transformers[0].description}. "
            f"Install the missing grids with pyproj sync.")

    best = usable[0]
    if verbose:
        print(f"  β†’ using: {best.description[:48]} ({best.accuracy} m), valid for {best.area_of_use.name[:30]}\n")
    return best


check_transformation(4326, 27700, required_accuracy_m=5.0,
                     area=AreaOfInterest(-2.2, 53.5, -1.2, 54.0))          # West Yorkshire
check_transformation(4267, 4326, required_accuracy_m=1.0)                  # NAD27 β†’ WGS 84, anywhere
try:
    check_transformation(4267, 4326, required_accuracy_m=1.0,
                         area=AreaOfInterest(-105.5, 39.5, -104.5, 40.0))  # NAD27 β†’ WGS 84, Denver
except RuntimeError as err:
    print(f"RuntimeError: {err}")
WGS 84 β†’ OSGB36 / British National Grid
  βœ“     2.0 m  Inverse of OSGB36 to WGS 84 (6) + British National Grid 
  βœ—    10.0 m  Inverse of OSGB36 to WGS 84 (2) + British National Grid 
  βœ—    21.0 m  Inverse of OSGB36 to WGS 84 (3) + British National Grid 
  βœ—    21.0 m  Inverse of OSGB36 to WGS 84 (1) + British National Grid 
  βœ— unavailable: Inverse of OSGB36 to WGS 84 (9) + British Na
      needs ['uk_os_OSTN15_NTv2_OSGBtoETRS.tif'] β€” pyproj sync --file uk_os_OSTN15_NTv2_OSGBtoETRS.tif
  β†’ using: Inverse of OSGB36 to WGS 84 (6) + British Nation (2.0 m), valid for United Kingdom (UK) - Great Br

NAD27 β†’ WGS 84
  βœ—    20.0 m  NAD27 to WGS 84 (3) (with axis order normalized for visu
  βœ—    10.0 m  NAD27 to WGS 84 (4) (with axis order normalized for visu
  βœ—     8.0 m  NAD27 to WGS 84 (13) (with axis order normalized for vis
  βœ—     7.0 m  NAD27 to WGS 84 (6) (with axis order normalized for visu
  βœ— unavailable: NAD27 to WGS 84 (33)
      needs ['ca_nrc_ntv2_0.tif'] β€” pyproj sync --file ca_nrc_ntv2_0.tif
  βœ— unavailable: NAD27 to WGS 84 (79)
      needs ['us_noaa_conus.tif'] β€” pyproj sync --file us_noaa_conus.tif
  βœ— unavailable: NAD27 to WGS 84 (85)
      needs ['us_noaa_alaska.tif'] β€” pyproj sync --file us_noaa_alaska.tif
  β†’ using: NAD27 to WGS 84 (88) (with axis order normalized (1.0 m), valid for Cuba - onshore and offshore.

NAD27 β†’ WGS 84
  βœ—     7.0 m  NAD27 to WGS 84 (6) (with axis order normalized for visu
  βœ—    10.0 m  NAD27 to WGS 84 (4) (with axis order normalized for visu
  βœ—    20.0 m  NAD27 to WGS 84 (3) (with axis order normalized for visu
  βœ— unavailable: NAD27 to WGS 84 (42)
      needs ['us_noaa_cohpgn.tif', 'us_noaa_conus.tif'] β€” pyproj sync --file us_noaa_cohpgn.tif
  βœ— unavailable: NAD27 to WGS 84 (79)
      needs ['us_noaa_conus.tif'] β€” pyproj sync --file us_noaa_conus.tif
  βœ— unavailable: NAD27 to WGS 84 (33)
      needs ['ca_nrc_ntv2_0.tif'] β€” pyproj sync --file ca_nrc_ntv2_0.tif
RuntimeError: no transformation from NAD27 to WGS 84 meets 1.0 m. Best available: NAD27 to WGS 84 (6) (with axis order normalized for visualization). Install the missing grids with pyproj sync.

The NAD27 cases are the lesson. Asked with no area, the check found a 1 m transformation and passed β€” but the pipeline it picked is valid for Cuba. For data in Denver, where the accurate transformations need NOAA's us_noaa_conus.tif grid, the best available is 7 m and the check fails, naming the grid. A transformation group without an area of interest contains every transformation between the two datums anywhere on earth, and "the most accurate one" may be for somewhere else entirely. Always pass area_of_interest.

Running this at the start of a pipeline turns a silent degradation into a startup failure that names the grid file to install. The pyproj sync hint is the part that makes it actionable rather than merely alarming.

The accuracy of -1.0 for ballpark is a PROJ convention meaning unknown β€” which is why the filter checks 0 <= accuracy rather than just an upper bound.

Example 2: which transformation made your data?

NaPTAN's two sets of coordinates are a real test. Transform its longitudes and latitudes with every available pipeline, and see which one reproduces its own eastings and northings:

def compare_transformations(points_4326, easting, northing, target_crs=27700):
    """How far is each available pipeline from the publisher's own projected coordinates?"""
    geod = Geod(ellps="WGS84")
    lons, lats = points_4326.geometry.x.to_numpy(), points_4326.geometry.y.to_numpy()
    group = TransformerGroup(4326, target_crs, always_xy=True)

    print(f"{len(group.transformers)} pipelines, {len(lons):,} points\n")
    print(f"{'pipeline':<34}{'stated':>8}{'vs published E/N':>20}{'round trip':>12}")
    for t in group.transformers:
        xs, ys = t.transform(lons, lats)
        off = np.hypot(xs - easting, ys - northing)
        blon, blat = t.transform(xs, ys, direction="INVERSE")     # the same pipeline, backwards
        _, _, rt = geod.inv(lons, lats, blon, blat)
        acc = "?" if t.accuracy in (None, -1.0) else f"{t.accuracy:g} m"
        name = t.description.split(" + ")[0].replace("Inverse of ", "")[:32]
        print(f"{name:<34}{acc:>8}{f'median {np.median(off):.3f} m':>20}{f'{np.median(rt) * 1000:.1f} mm':>12}")


pyproj.network.set_network_enabled(True)
compare_transformations(stops, stops["Easting"].to_numpy(), stops["Northing"].to_numpy())
pyproj.network.set_network_enabled(False)
9 pipelines, 16,605 points

pipeline                            stated    vs published E/N  round trip
OSGB36 to WGS 84 (9)                   1 m      median 1.700 m      0.0 mm
OSGB36 to WGS 84 (6)                   2 m      median 0.001 m      0.9 mm
OSGB36 to WGS 84 (1)                  21 m      median 0.522 m      0.8 mm
OSGB36 to WGS 84 (2)                  10 m      median 4.665 m      0.8 mm
OSGB36 to WGS 84 (3)                  21 m      median 4.580 m      0.8 mm
OSGB36 to WGS 84 (4)                  18 m     median 11.228 m      0.8 mm
OSGB36 to WGS 84 (5)                  35 m      median 6.247 m      0.8 mm
OSGB36 to WGS 84 (8)                   3 m      median 6.045 m      0.8 mm
Ballpark geographic offset from          ?    median 102.957 m      0.0 mm

Three readings, and the first is the surprise. The publisher's coordinates match the 2 m Helmert, (6), to about a millimetre β€” so that is the transformation NaPTAN's longitudes and latitudes were made with. The grid-based OSTN15, the most accurate pipeline PROJ has, disagrees with the published eastings and northings by nearly two metres. If your job is to overlay these stops on British National Grid data, the "best" transformation is the one that moves them away from where their publisher put them.

The ballpark result is about a hundred metres out, which is the full size of the OSGB36 offset in West Yorkshire β€” no datum transformation at all.

And the round trips are the trap: every pipeline returns to within about a millimetre of where it started, ballpark included, because each is internally consistent. A round-trip test proves consistency, never correctness.

Example 3: pinning transformations for a project

# in a project this is one module, say transforms.py, imported before anything transforms
MIN_ACCURACY_M = 5.0

REQUIRED = [
    (4326, 27700, "GPS to British National Grid", MIN_ACCURACY_M),
    (27700, 4326, "British National Grid to GPS", MIN_ACCURACY_M),
    (4326, 3857, "GPS to Web Mercator (display only)", None),     # a conversion: no accuracy is stated
]
WEST_YORKSHIRE = AreaOfInterest(-2.2, 53.5, -1.2, 54.0)


def _build(src, dst, label, floor):
    group = TransformerGroup(pyproj.CRS.from_user_input(src), pyproj.CRS.from_user_input(dst),
                             always_xy=True, area_of_interest=WEST_YORKSHIRE)
    if floor is None:
        return group.transformers[0]
    usable = [t for t in group.transformers if t.accuracy is not None and 0 <= t.accuracy <= floor]
    if not usable:
        missing = sorted({g.short_name for op in group.unavailable_operations for g in op.grids})
        raise RuntimeError(
            f"{label}: no transformation within {MIN_ACCURACY_M} m. "
            f"Best available: {group.transformers[0].description}. "
            + (f"Missing grids: {missing}. Run: pyproj sync --file {missing[0]}" if missing else ""))
    return usable[0]


TRANSFORMS = {(src, dst): _build(src, dst, label, floor) for src, dst, label, floor in REQUIRED}


def provenance():
    return {
        "proj": pyproj.proj_version_str,
        "pyproj": pyproj.__version__,
        "network": pyproj.network.is_network_enabled(),
        "pipelines": {f"{s}->{d}": t.description for (s, d), t in TRANSFORMS.items()},
    }


print(json.dumps(provenance(), indent=2))
{
  "proj": "9.8.1",
  "pyproj": "3.8.0",
  "network": false,
  "pipelines": {
    "4326->27700": "Inverse of OSGB36 to WGS 84 (6) + British National Grid (with axis order normalized for visualization)",
    "27700->4326": "Inverse of British National Grid + OSGB36 to WGS 84 (6) (with axis order normalized for visualization)",
    "4326->3857": "Popular Visualisation Pseudo-Mercator (with axis order normalized for visualization)"
  }
}

Building the transformers up front means a container missing a required grid fails on startup, with a message naming the file and the pyproj sync command β€” not four hours later with coordinates that are quietly out. Because each entry is a single pipeline from TransformerGroup, its description is concrete, unlike a transformer from from_crs.

Notice what it pinned with the network off: the Helmert (6), which is within the 5 m floor. On a machine with OSTN15 installed the same code would pin the grid instead β€” and Example 2 shows that for NaPTAN data that would be the less consistent choice. Pinning makes the choice explicit; it does not make it right. Deciding which pipeline your sources used is still your job.

Projection change and datum shift are separate

A to_crs call decomposed into a datum change and a projection change. one call, two operationsWGS 84 lon/latinputdatum shifta measurementOSGB36 lon/latmovedprojecta formulaThe formula is deterministic; the measurement has several versions spatialworkflow.io
One call, two operations. The projection is a formula; the datum shift is a measurement.

A coordinate transformation between two CRS is really two operations, and confusing them explains most of the surprises here.

The first is a projection change β€” converting between angles on an ellipsoid and coordinates on a plane. It is a deterministic formula with no ambiguity: the same input always gives the same output, to floating-point precision.

The second is a datum change β€” moving between two different models of the earth's shape and position. That is not a formula but an empirical measurement, and it is where the ambiguity lives. WGS 84 is geocentric and global; OSGB36 uses the Airy 1830 ellipsoid, fitted to Britain in the nineteenth century and positioned to match a network of triangulation stations. The relationship between them varies across the country because the historical survey has its own distortions, so it can be modelled in several ways, at several accuracies.

That is why more than one transformation exists, and why they disagree:

  • A null transformation treats the datums as coincident. In Britain it is out by up to 120 m. PROJ calls this "Ballpark".
  • A 7-parameter Helmert transformation applies a global rotation, translation and scale. Better β€” PROJ's best one for Britain states 2 m β€” but it cannot capture local distortion.
  • A grid-based transformation stores the measured offset at thousands of points and interpolates. OSTN15 achieves about 1 m, because it models the actual survey rather than an idealised relationship.
What has to be true about transformation grids for two machines to agree. the grid is data, and it ships separatelyThe grid is installed herenot just on your laptopPROJ_NETWORK decided explicitlyon means a runtime fetchThe grids pinned in the imageor the answer movesA round trip proves nothingit returns what it started withA machine without the grid falls back to a ballpark transformation and says nothing spatialworkflow.io
The round trip is the trap: a wrong transformation inverts to itself perfectly.

The grid is data, not code. It ships separately, is updated occasionally, and is the thing most likely to be missing on a fresh machine β€” which is exactly why the same code gives different answers in different environments.

PROJ_NETWORK turns that into a runtime dependency. With it on, PROJ fetches grids from a CDN as needed. This is genuinely helpful for interactive work and unacceptable for anything reproducible, because the result now depends on connectivity and cache state at the moment of execution. Installing proj-data and turning the network off makes a missing grid a visible failure instead.

The round-trip test is the trap worth naming. Transforming there and back returns almost exactly the original coordinates under every pipeline, including the ballpark one, because each is internally consistent. A perfect round trip proves the transformation is invertible, not that it is right. The real checks are which operation ran β€” get_last_used_operation() β€” and, where the source publishes them, whether you reproduce its own projected coordinates. NaPTAN's matched the Helmert to a millimetre and the "best" grid only to 1.7 m, which no round trip would ever reveal.

Finally, note that the accuracy required depends entirely on the use. A 1:50,000 map has a line width worth about 25 m on the ground, so a 3 m transformation is beyond adequate. A cadastral boundary, a buried utility, or anything with legal weight needs the grid. Knowing which situation you are in is the decision; everything above is how to act on it.

Ballpark results, unknown accuracy and pyproj sync

  • "Ballpark" means PROJ found no proper transformation. Error can exceed 100 m; never publish without saying so.
  • accuracy = -1.0 means unknown, not excellent. Filter with 0 <= accuracy <= limit.
  • PROJ_NETWORK is read at import. Set it before import pyproj, or use pyproj.network.set_network_enabled.
  • A perfect round trip proves consistency, not correctness. Ballpark round-trips perfectly too.
  • always_xy=True forces lon/lat order. Without it, EPSG:4326's declared axis order is lat/lon, and results silently swap.
  • TransformerGroup.unavailable_operations names the grid files you are missing.
  • pyproj sync --area-of-use "United Kingdom" (or PROJ's own projsync) fetches only the relevant grids instead of the full set.
  • Transformer.from_crs(...).description can read "unavailable until proj_trans is called" even after transforming. Log get_last_used_operation().description.
  • TransformerGroup without area_of_interest includes transformations for anywhere on earth β€” the "best" NAD27 one was for Cuba.
  • only_best=True returns inf when the best grid is missing, after only a warning.
  • ETRS89 and WGS 84 are diverging by about 2.5 cm per year as the Eurasian plate moves β€” roughly 0.9 m since 1989.
  • +towgs84= in a PROJ string is a 7-parameter shift, less accurate than a grid and often the reason results differ.
  • GeoPandas' to_crs uses the same machinery, so everything here applies to it.

FAQ

Why do I get different coordinates on different machines?

Different transformation pipelines, because the grid files available differ. Print transformer.get_last_used_operation().description on both β€” one is probably using the OSTN15 grid and the other a Helmert. On this site's test machine the two put Leeds station 1.8 m apart.

What does "Ballpark" mean in a transformation description?

PROJ found no proper datum transformation and treated the two datums as coincident. In Britain that is an error of up to about 120 m. Install the grid file.

How do I install the transformation grids?

conda install -c conda-forge proj-data for the full set, or pyproj sync --area-of-use "United Kingdom" for just the relevant ones. A pip install pyproj ships none.

Should PROJ_NETWORK be on or off?

Off for anything reproducible. With it on, grids are fetched at runtime, so results depend on connectivity and cache state. Install proj-data instead.

My round trip is accurate to a millimetre. Does that mean the transformation is right?

No. Every pipeline is internally consistent and inverts itself cleanly, including the ballpark one. Check which operation ran, and compare against the source's own coordinates where it publishes them β€” not the round trip.

How accurate does my transformation need to be?

For a 1:50,000 map, a 3 m Helmert is more than enough. For cadastral, utility or legal work, use the grid-based transformation β€” about 1 m β€” and record which one you used.

What is always_xy=True for?

It forces longitude-then-latitude ordering. EPSG:4326 formally declares latitude first, so without it your coordinates can silently swap.