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
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
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
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 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.
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.0means unknown, not excellent. Filter with0 <= accuracy <= limit.PROJ_NETWORKis read at import. Set it beforeimport pyproj, or usepyproj.network.set_network_enabled.- A perfect round trip proves consistency, not correctness. Ballpark round-trips perfectly too.
always_xy=Trueforces lon/lat order. Without it, EPSG:4326's declared axis order is lat/lon, and results silently swap.TransformerGroup.unavailable_operationsnames the grid files you are missing.pyproj sync --area-of-use "United Kingdom"(or PROJ's ownprojsync) fetches only the relevant grids instead of the full set.Transformer.from_crs(...).descriptioncan read "unavailable until proj_trans is called" even after transforming. Logget_last_used_operation().description.TransformerGroupwithoutarea_of_interestincludes transformations for anywhere on earth β the "best" NAD27 one was for Cuba.only_best=Truereturnsinfwhen 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_crsuses the same machinery, so everything here applies to it.
Internal links
- Coordinate reference systems explained for Python GIS β datums and ellipsoids
- Projected vs geographic CRS β the projection half of a transformation
- How to reproject spatial data in Python (GeoPandas) β the everyday operation
- Reproducible GIS environments explained β pinning PROJ and its data
- How to work with a custom or local CRS in Python β defining a CRS with a datum
- How to record run metadata and data lineage in a GIS pipeline β storing the pipeline with results
- EPSG codes explained: how to choose the right CRS in Python β what a code specifies
- How to measure distance accurately in Python β where a metre matters
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.