Fixing PyProj CRS Transformation Errors: Axis Order & EPSG Validation
A pyproj transform that raises CRSError on the first call is annoying but honest; the one that returns numbers off by thousands of kilometres without complaint is the one that ships bad data. This guide isolates both failure modes — hard CRSError/ProjError exceptions and silent latitude/longitude swaps — and gives a deterministic fix for each. It is for anyone wiring Coordinate Systems with PyProj into a production pipeline within Mastering Core Geospatial Python Libraries, and it pairs with Choosing the Right UTM Zone Automatically in Python once you know which projected CRS you should be transforming into.
Why This Approach / What Goes Wrong
PROJ 6 rewrote how coordinate reference systems are described. The old behaviour — treating every CRS as if it emitted (x, y) in the order you happened to want — is gone. Modern PROJ (bundled with pyproj>=3.0) honours the axis order declared in the EPSG registry, and for geographic CRSs like EPSG:4326 that order is latitude first, longitude second. Two distinct problems fall out of this.
The first is the hard failure. Passing a raw integer, a hand-written +proj= string with a typo, or a deprecated datum alias to Transformer.from_crs() raises CRSError (the CRS could not be built) or ProjError (the transformation pipeline could not be constructed). These are loud and easy to fix once you know to validate the CRS objects before you build the transformer.
The second is the silent swap, and it is far more dangerous. A Transformer built from EPSG:4326 expects its input as (lat, lon) by default. If your data — like almost every GeoJSON, shapefile, or API response in the wild — is ordered (lon, lat), PyProj will happily transform it, feeding your longitude in where it expects latitude. Nothing raises. You just get an easting and northing for the wrong place. The fix is always_xy=True, which forces the traditional GIS (lon, lat) / (x, y) convention on both ends regardless of what the EPSG registry says. This single flag is the most common cause of "my coordinates are in the ocean" bug reports on this site; the same trap is covered from the reprojection angle in Coordinate Reference System Transformations.
There is a third class, and it sits between the two. PROJ chooses an operation from a ranked candidate list, and when the highest-accuracy candidate needs a datum-shift grid that is not present on disk it quietly falls back to a lower-ranked one — a Helmert approximation, or in the worst case a "ballpark" null offset that pretends the two datums coincide. Nothing in the returned tuple distinguishes a 0.01 m grid-based result from a 2 m ballpark. The tell-tale symptom is a round-trip that closes to nine decimal places while the coordinates disagree with a surveyed control point, because the same wrong operation ran in both directions and its error cancelled itself out. Chasing that class down is a separate exercise, covered in datum shifts and transformation grids in PyProj; what matters here is knowing it exists, so that you never read a clean round-trip as proof of accuracy — only as proof of self-consistency.
Prerequisites
pyproj>=3.4— provides the PROJ 9 backend,always_xy, and network grid downloadpython>=3.10- (optional)
geopandas>=0.14— if you reprojectGeoDataFrames rather than raw arrays
conda install -c conda-forge "pyproj=3.4.*" "geopandas=0.14.*"
Install PyProj from conda-forge, not a bare pip wheel layered on top of a conda GDAL — a mismatched PROJ database is the classic source of CRSError: Invalid projection on codes that are perfectly valid. Confirm the versions with python -c "import pyproj; print(pyproj.__version__, pyproj.proj_version_str)"; you want PROJ 8+ under PyProj 3.4+.
Step-by-Step Implementation
The worked example transforms an air-quality sensor in Berlin from WGS 84 lon/lat into a projected, metre-based CRS suitable for distance and area work. Berlin sits in UTM zone 33N, so the correct target is EPSG:25833 (ETRS89 / UTM zone 33N) — a proper projected CRS, not Web Mercator, which distorts metric measurement badly at high latitudes.
1. Build and validate the CRS objects before you build the transformer. CRS.from_epsg() hits the PROJ registry and rejects bad codes immediately, instead of deferring the error to transform time.
from pyproj import CRS
# Sensor metadata says coordinates are WGS 84 lon/lat; target is metric UTM 33N.
source_crs = CRS.from_epsg(4326) # WGS 84 (geographic, degrees)
target_crs = CRS.from_epsg(25833) # ETRS89 / UTM zone 33N (metres)
# Fail fast on a malformed or unknown definition
assert source_crs.is_valid and target_crs.is_valid, "Invalid CRS definition"
assert target_crs.axis_info[0].unit_name == "metre", "Target CRS is not metric"
Prefer CRS.from_epsg(25833) over the shorthand integer 25833 you might pass elsewhere: it forces validation now and gives you a CRS object you can inspect (.axis_info, .is_geographic, .to_wkt()).
2. Construct the Transformer with an explicit axis order. This is the line that fixes the silent swap.
from pyproj import Transformer
# always_xy=True => inputs and outputs are (lon, lat) / (easting, northing),
# regardless of the lat-first axis order EPSG:4326 declares.
transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True)
3. Transform using the (lon, lat) -> (x, y) convention.
sensor_lon, sensor_lat = 13.404954, 52.520008 # Berlin, WGS 84
easting, northing = transformer.transform(sensor_lon, sensor_lat)
print(f"{easting:.2f}, {northing:.2f}")
# 391776.16, 5820073.12
4. See the failure for yourself. Rebuild the transformer without always_xy and feed it the same (lon, lat) tuple your data actually carries. PROJ reads the first value as latitude, and the result lands nowhere near Berlin — with no exception raised.
wrong = Transformer.from_crs(source_crs, target_crs) # always_xy defaults to False
bad_e, bad_n = wrong.transform(sensor_lon, sensor_lat) # lon fed where lat is expected
print(f"{bad_e:.2f}, {bad_n:.2f}")
# 4847457.95, 1852284.37 <- silently wrong, ~4000 km off
5. Reuse the transformer; never rebuild it per point. Each Transformer.from_crs() call triggers a PROJ database lookup and can trigger grid-file loading. Instantiate once and cache it — module-level, or via functools.lru_cache keyed on the EPSG pair.
from functools import lru_cache
from pyproj import Transformer
@lru_cache(maxsize=32)
def get_transformer(src_epsg: int, dst_epsg: int) -> Transformer:
return Transformer.from_crs(src_epsg, dst_epsg, always_xy=True)
tf = get_transformer(4326, 25833)
# Vectorised: pass arrays, not a Python loop, for large sensor batches
lons = [13.404954, 13.388860, 13.428555]
lats = [52.520008, 52.517037, 52.523430]
xs, ys = tf.transform(lons, lats)
If you are moving whole layers rather than coordinate arrays, GeoPandas .to_crs() delegates to PyProj and applies always_xy semantics internally, so you only manage axis order by hand when you extract raw coordinates. For layers too large to reproject in memory, see Reprojecting Large Datasets Without Memory Errors.
6. Decide whether a bad coordinate should raise or be flagged. By default PROJ writes an infinity sentinel into any output slot it could not compute and returns normally, which is exactly the behaviour that lets a corrupt row travel three stages downstream before anyone notices. errcheck=True converts that sentinel into a ProjError at the point of failure.
import numpy as np
from pyproj import Transformer
tf = Transformer.from_crs(4326, 25833, always_xy=True)
# One corrupt row: a latitude of 152.52 cannot exist
lons = np.array([13.404954, 13.388860, 13.428555])
lats = np.array([52.520008, 52.517037, 152.523430])
# Default behaviour — the bad row comes back as inf, the good rows are untouched
easting, northing = tf.transform(lons, lats)
print(np.isfinite(easting))
# [ True True False]
# errcheck=True raises on the first coordinate PROJ cannot handle
try:
tf.transform(lons, lats, errcheck=True)
except Exception as exc: # pyproj.exceptions.ProjError
print(type(exc).__name__, "->", exc)
# ProjError -> Error ... latitude or longitude exceeded limits
Pick the mode that matches the caller. A request handler transforming a single user-supplied coordinate wants errcheck=True, because an exception maps cleanly onto a 400 response and there is nothing to salvage. A nightly batch of ten million sensor fixes wants the default, because raising discards the 9,999,999 rows that were fine; there you transform the whole array, mask with np.isfinite, quarantine the offending rows, and carry on. The check itself is a single comparison per coordinate, so the cost is irrelevant next to the projection maths — the choice is about error semantics, not speed.
7. Pin the operation to your study area when several candidates exist. For CRS pairs served by regional grids — NAD27 to NAD83, OSGB36 to ETRS89, many national realizations — PROJ may know a dozen operations with different footprints and accuracies. Without a hint it ranks them across the whole overlap of the two systems, which can select a nationwide low-accuracy operation when a precise regional one covers your data. area_of_interest (pyproj 3.0+) narrows the ranking.
from pyproj import Transformer
from pyproj.aoi import AreaOfInterest
# The AOI is ALWAYS given in lon/lat degrees, regardless of always_xy
berlin_extent = AreaOfInterest(
west_lon_degree=13.0, south_lat_degree=52.3,
east_lon_degree=13.8, north_lat_degree=52.7,
)
tf_scoped = Transformer.from_crs(
"EPSG:4326", "EPSG:25833", always_xy=True, area_of_interest=berlin_extent
)
tf_scoped.transform(13.404954, 52.520008) # operation is chosen on first use
print(tf_scoped.description)
# Inverse of ETRS89 to WGS 84 (1) + UTM zone 33N
Two details bite people here. The AOI is expressed in longitude/latitude degrees even when always_xy=True is set — that flag governs coordinate arguments, not the AOI constructor. And transformer.description reports unavailable until proj_trans is called until you have pushed at least one coordinate through, because operation selection is deferred to the first transform; print it after a call, never before.
Verification
Prove the fix with a round-trip and a bounds check. Expected console output is shown inline as comments.
import math
from pyproj import Transformer
fwd = Transformer.from_crs(4326, 25833, always_xy=True)
inv = Transformer.from_crs(25833, 4326, always_xy=True)
sensor_lon, sensor_lat = 13.404954, 52.520008
easting, northing = fwd.transform(sensor_lon, sensor_lat)
# 1. Forward result matches the known-good UTM 33N easting/northing for Berlin
assert math.isclose(easting, 391776.16, abs_tol=0.5), f"Unexpected easting: {easting}"
assert math.isclose(northing, 5820073.12, abs_tol=0.5), f"Unexpected northing: {northing}"
# 2. Round-trip back to lon/lat returns the original within floating-point tolerance
lon_rt, lat_rt = inv.transform(easting, northing)
assert math.isclose(lon_rt, sensor_lon, abs_tol=1e-6)
assert math.isclose(lat_rt, sensor_lat, abs_tol=1e-6)
print(f"round-trip OK: {lon_rt:.6f}, {lat_rt:.6f}")
# round-trip OK: 13.404954, 52.520008
A clean round-trip that lands back on the input is the strongest single signal that both the axis order and the CRS pair are correct. If the round-trip drifts by whole metres or the forward result misses the assertion, you almost certainly swapped an axis or targeted the wrong zone.
A single point, though, only proves that one point worked. Batch pipelines need an assertion over the whole array, and the two cheapest ones are a finiteness mask and a zone-extent check. UTM eastings are defined with a 500,000 m false easting and a 6° wide zone, so every legitimate easting in any UTM zone falls between roughly 166,000 m and 834,000 m; a value outside that band means the point is not in the zone you targeted, whatever the transform returned.
import numpy as np
lons = np.array([13.404954, 13.388860, 13.428555])
lats = np.array([52.520008, 52.517037, 152.523430]) # row 2 is corrupt
easting, northing = fwd.transform(lons, lats)
# 1. Isolate the rows PROJ could not compute instead of trusting a spot check
failed = ~(np.isfinite(easting) & np.isfinite(northing))
print("failed row indices:", np.flatnonzero(failed))
# failed row indices: [2]
# 2. Every surviving easting must sit inside the UTM zone's defined band
ok = ~failed
assert np.all((easting[ok] > 166_000) & (easting[ok] < 834_000)), "easting outside zone 33"
# 3. Northings in the northern hemisphere run 0 to ~9,330,000 m
assert np.all((northing[ok] >= 0) & (northing[ok] < 9_400_000)), "northing outside hemisphere"
print(f"{ok.sum()} of {len(lons)} rows transformed cleanly")
# 2 of 3 rows transformed cleanly
Run those three checks as a fixture in CI rather than as a one-off. They cost microseconds on a million-row array and they catch the two failures that unit tests on a single hard-coded point cannot: a partially corrupt input batch, and a target zone that was correct for last quarter's extract but not for this one.
Edge Cases & Debugging
CRSError: Invalid projectionon a valid EPSG code. The PROJ database is mismatched — a pip wheel layered over a conda install. Reinstall PyProj fromconda-forgeand confirmpyproj.proj_version_strreports PROJ 8+.- Result is
infornan. The input fell outside the CRS's area of use (e.g. a point outside UTM zone 33 pushed intoEPSG:25833). Bounds-check first:-180 <= lon <= 180,-90 <= lat <= 90, and confirm the point lies inside the target zone. - Missing datum grids on a time-dependent transform. Transforms like ITRF2014 → NAD83 need shift grids that may be absent in a container. Set
PROJ_NETWORK=ON(orTransformer.from_crs(..., allow_ballpark=False)to reject the low-accuracy fallback) and verifypyproj.datadir.get_data_dir()resolves inside the image. - Legacy
+proj=string gives coordinates off by ~100 m. A bare PROJ.4 string such as+proj=utm +zone=33 +datum=WGS84carries no explicit datum-shift parameters. Rebuild the CRS from EPSG or WKT2 (CRS.from_epsg(25833)/CRS.from_wkt(...)) so the datum transformation is fully specified. - Silent swap survives into GeoPandas. If a
GeoDataFramelooks mirrored after.to_crs(), the source layer was tagged with the wrong CRS in the first place. Declare the true input withset_crs()(neverto_crs()), then reproject. - Inspect the actual pipeline PROJ chose. When a transform is merely inaccurate rather than broken, print
transformer.descriptionandtransformer.definition, or enablelogging.getLogger("pyproj").setLevel(logging.DEBUG), to see the exact pipeline string and whether a ballpark step slipped in. AttributeError: module 'pyproj' has no attribute 'transform'. The module-levelpyproj.transform()and theProj-to-Projcalling convention were deprecated in pyproj 2.2 and removed in 3.x. Rewrite asTransformer.from_crs(src, dst, always_xy=True).transform(x, y)— and note that the old function took(x, y)order, so a mechanical port withoutalways_xy=Truereintroduces the swap the removal was meant to prevent.- Works in the parent process, fails inside workers. A
Transformerholds a pointer into a PROJ context that does not survivefork, so a transformer built at import time and inherited by amultiprocessingor Dask worker can segfault or return garbage. Build it inside the worker — anlru_cachefactory called from worker code does this naturally, one cache per process. - A
GeoDataFrametransforms but rows come back empty.to_crs()propagatesNoneand empty geometries rather than raising, so a layer with missing geometry silently loses features at the next spatial predicate. Assertgdf.geometry.notna().all()and(~gdf.geometry.is_empty).all()before and after the reprojection.
Frequently Asked Questions
Should always_xy=True just be the default in every project?
In practice, yes — set it on every Transformer.from_crs() call and treat a transformer built without it as a code-review failure. The reason is that essentially every data format you will actually meet stores coordinates x-first: GeoJSON mandates longitude-then-latitude, shapefiles and GeoPackages store x-then-y, and both Shapely and GeoPandas expose .x and .y in that order. The authority-declared latitude-first ordering for EPSG:4326 is correct as metadata and almost never matches the bytes on disk. The narrow exception is when you are deliberately consuming a service that honours the registry order — some WFS 1.1.0 and WMS 1.3.0 endpoints do — in which case leave the flag off, but comment the line so the next reader knows it was a decision rather than an omission.
Why does my round-trip close perfectly when the coordinates are still wrong? Because a round-trip only tests that the forward and inverse operations are mutual inverses, and a wrong operation is still its own inverse. If PROJ selected a ballpark datum shift, the same ballpark runs in reverse and its error cancels exactly, so the check returns to the input to within floating-point noise. The same is true of an axis swap applied consistently in both directions. A round-trip proves internal consistency; only an external reference proves accuracy. Add at least one known control point — a published survey mark, or a coordinate you have verified in another tool — and assert against its true value, not against your own input.
Is it safe to share one Transformer across threads or processes?
Across threads, yes on pyproj 3.7 and later: the library maintains one PROJ context per thread automatically, which is why pyproj.set_use_global_context() is now deprecated. Across processes, no. A transformer created before a fork carries a context that is not valid in the child, and the failure mode ranges from a segfault to silently wrong output rather than a clean exception. The safe pattern is a module-level lru_cached factory that workers call after starting, so each process compiles its own pipeline once. On Windows and macOS, where spawn is the default start method, the problem does not arise because nothing is inherited — which is exactly why this bug tends to appear only when a pipeline moves to a Linux host.
My code broke after upgrading from pyproj 2.x — what actually changed?
Three removals account for almost all of it. pyproj.transform(p1, p2, x, y) and the Proj-object calling convention are gone, replaced by Transformer. +init=epsg:4326 style strings are rejected; use EPSG:4326 or CRS.from_epsg(4326). And PROJ 6 introduced authority axis order, so code that previously got away with passing (lon, lat) now needs always_xy=True to keep doing so. The upgrade is worth it — the Transformer object is what makes caching and vectorized array transforms possible — but do it with a regression fixture of known coordinate pairs rather than by inspection, because the axis change is the one that produces no error at all.
How do I catch an axis swap in a test instead of in production?
Assert on the extent rather than on individual points. After building geometry, compare total_bounds against the bounding box you expect the dataset to occupy; a swapped pair puts longitude values where latitudes belong and the bounds immediately exceed ±90 in the y slot, which is impossible for real latitudes. For projected output, the UTM easting band check shown under Verification does the same job in metres. Both are one-line assertions that run in microseconds and fail loudly on the exact class of bug that produces no exception, which makes them far more valuable in CI than a broader but slower geometry validity sweep.
Does Transformer.from_crs() hit the network?
Only if grid downloads are enabled and the chosen operation needs a grid that is not on disk. Building the transformer itself is a local proj.db lookup; the network fetch, when it happens, is triggered lazily by the first coordinate and pulls only the byte ranges of the GeoTIFF grid that cover your extent. In a container that means a first-call latency spike and a hard dependency on the PROJ CDN being reachable. If either is unacceptable, bake the grids you need into the image and leave PROJ_NETWORK off, then assert the operation you expected was actually used before serving traffic.