Catching Axis-Order Swaps in CRS Validation
The most frustrating coordinate bug is the one that passes every projection check. A dataset can carry the correct SRID, resolve cleanly against the EPSG registry, and still place every feature on the wrong side of the planet — because its coordinates are stored longitude-first while the authority definition says latitude-first, or vice versa. EPSG:4326 formally declares latitude before longitude, yet most software, file formats, and developers assume x/y ordering, and the resulting transposition slips through an SRID assertion untouched. This guide covers how to detect swapped-axis coordinates with bounding-box sanity and coordinate-range assertions. It sits under Coordinate Reference System Validation and within the wider Spatial Data Freshness & Quality Metrics program, and it is written for the data engineers and GIS platform administrators who own ingestion correctness.
Why an SRID check cannot see it
An SRID assertion answers one question — which coordinate reference system is this — and a transposed dataset answers it correctly. The projection identity, the datum, and the units are all exactly right; only the order in which the two ordinates are written has flipped. Because ST_SRID reads a metadata tag and never inspects the coordinate values, a latitude-first payload labelled EPSG:4326 sails through the runtime gate described in the parent Coordinate Reference System Validation guide, and it sails through an SRID contract test in CI just as cleanly. The fault is orthogonal to projection identity, so it needs a value-level check, not a metadata one.
The trap has deep roots. EPSG:4326 — like many geographic CRSs in the authority database — declares its axes in the order latitude, longitude. But the GIS ecosystem grew up on x/y, so shapefiles, GeoJSON (which mandates longitude-first), most PostGIS workflows, and virtually every developer’s mental model put longitude first. Whenever data crosses a boundary between a strictly-authority-compliant component and an x/y-assuming one, the ordinates can transpose. The classic offender is a WMS version bump: WMS 1.1.1 uses longitude/latitude bounding boxes, while WMS 1.3.0 respects the authority axis order and expects latitude/longitude for EPSG:4326. A client that upgrades the protocol version without swapping its BBOX parameter requests the wrong rectangle of the earth, and a server that returns tiles for it embeds the swap into every cached product.
Bounding-box sanity as the primary detector
The reliable detector is a spatial-context assertion: the data’s coordinates must fall inside the geographic extent it is supposed to occupy. A Swedish cadastral layer has a known bounding box; if its features land off the coast of Somalia, the axes are swapped. This works because transposition produces a large, obvious displacement whenever the true longitude and latitude differ substantially — which they almost always do outside a narrow band near the equator and prime meridian.
In PostGIS, the check is a range assertion on ST_X and ST_Y against the declared extent. For any point in a European dataset, longitude and latitude occupy disjoint ranges, so a transposed row violates the bound immediately:
-- Flag features whose ordinates fall outside the dataset's known extent.
-- For a Nordic layer: lon roughly 4..32, lat roughly 54..70.
SELECT
id,
ST_X(geom) AS x_lon,
ST_Y(geom) AS y_lat
FROM staging.nordic_points
WHERE ST_SRID(geom) = 4326
AND (
ST_X(geom) NOT BETWEEN 4 AND 32 -- x must be a plausible longitude
OR ST_Y(geom) NOT BETWEEN 54 AND 70 -- y must be a plausible latitude
);
-- Rows where x_lon > 54 or y_lat < 32 are the tell-tale of a lat/lon swap.
A stronger, extent-agnostic heuristic catches the swap even when you do not want to hard-code a per-dataset box: for any geographic CRS, latitude is bounded to ±90 while longitude ranges to ±180. An ST_X (nominally longitude) value with magnitude above 90 is impossible for a correctly-ordered geographic coordinate, so it is a guaranteed swap — no local knowledge required.
-- Extent-agnostic swap probe: |x| must never exceed 90 if x is truly longitude
-- constrained to a dataset that stays off the ±90..±180 longitude band... but
-- the universal tell is the reverse: a Y (latitude) magnitude above 90 is impossible.
SELECT id, ST_X(geom) AS x, ST_Y(geom) AS y
FROM staging.points
WHERE ST_SRID(geom) = 4326
AND abs(ST_Y(geom)) > 90; -- latitude cannot exceed 90 — this row is transposed
Compute the whole layer’s extent once with ST_Extent and compare it to the expected envelope; a bounding box whose x-range exceeds ±90 or whose aspect is the mirror of what you expect is a layer-wide swap rather than a few bad rows.
Enforcing axis order at the transform boundary
Detection catches the swap after it happens; the durable fix is to make every reprojection explicit about ordering so the transposition never enters the store. In pyproj, always_xy=True forces longitude/latitude regardless of the CRS’s declared authority order, which is what keeps a latitude-first definition from silently transposing coordinates mid-transform.
from pyproj import Transformer, CRS
# always_xy=True: interpret and emit coordinates as (lon, lat) / (x, y),
# overriding the authority's lat-first axis order for EPSG:4326.
tf = Transformer.from_crs(CRS.from_epsg(4326), CRS.from_epsg(3857), always_xy=True)
def assert_axis_order(lon: float, lat: float, extent: tuple) -> None:
# Universal guard first: latitude physically cannot exceed 90 degrees.
if abs(lat) > 90:
raise ValueError(f"Latitude {lat} exceeds 90 — coordinates are transposed")
# Then the dataset-specific extent guard.
min_lon, min_lat, max_lon, max_lat = extent
if not (min_lon <= lon <= max_lon and min_lat <= lat <= max_lat):
raise ValueError(
f"Point ({lon}, {lat}) outside expected extent {extent} — likely axis swap"
)
tf.transform(lon, lat)
For WMS specifically, pin the axis convention to the protocol version rather than assuming one. A request built for 1.3.0 must order the BBOX as min_lat,min_lon,max_lat,max_lon for EPSG:4326, while the same rectangle under 1.1.1 is min_lon,min_lat,max_lon,max_lat. Encode that difference explicitly so a version upgrade cannot silently invert the request:
def wms_bbox(min_lon, min_lat, max_lon, max_lat, version: str, crs="EPSG:4326") -> str:
# WMS 1.3.0 honours the authority axis order (lat,lon for 4326);
# WMS 1.1.1 is always lon,lat.
if version == "1.3.0" and crs == "EPSG:4326":
return f"{min_lat},{min_lon},{max_lat},{max_lon}"
return f"{min_lon},{min_lat},{max_lon},{max_lat}"
Emitting the signal and gating it
A swap is a discrete, high-severity event, so it maps onto the CRS drift and unit-mismatch vocabulary from the parent guide rather than needing a new metric family. Record swapped features as a gis.spatial.crs.unit_mismatch_delta-adjacent counter tagged with the axis, and gate any nonzero count as critical — a transposed coordinate is a correctness failure, not a tolerance dip.
groups:
- name: spatial-axis-order
rules:
# Any feature outside its declared extent after an SRID pass is a probable swap
- alert: AxisOrderSwapSuspected
expr: increase(gis_spatial_crs_out_of_extent_total[15m]) > 0
labels: { severity: critical }
annotations:
summary: "Out-of-extent features on {{ $labels.dataset }} — check lat/lon axis order"
# The physically-impossible case: latitude magnitude over 90
- alert: ImpossibleLatitude
expr: increase(gis_spatial_crs_impossible_latitude_total[5m]) > 0
labels: { severity: critical }
annotations:
summary: "Latitude > 90 on {{ $labels.dataset }} — coordinates transposed at ingest"
Run the extent assertion as part of the same CI contract that pins projection identity, so a transform edit that flips axis order fails the pull request alongside an SRID change; that gate is detailed in Detecting CRS Drift in CI Pipelines. And because a swapped-axis layer often renders without error while breaking spatial predicates, pair the check with the structural validation in Geometry Validity & Topology Checks to rule out that a downstream join failure is a topology fault rather than a transposition.
FAQ
Why does a swapped-axis dataset pass an SRID check?
Because SRID and axis order are different properties. ST_SRID reads a metadata label naming the coordinate reference system; it never inspects the coordinate values or their ordering. A dataset stored latitude-first under EPSG:4326 has a perfectly correct SRID — the projection, datum, and units are right — so only a value-level check against the expected extent can see that the two ordinates have been transposed.
What is the single most reliable swap detector?
The physical bound on latitude. Latitude cannot exceed ±90 degrees, but longitude ranges to ±180, so any nominal-latitude value with magnitude above 90 is impossible for a correctly-ordered geographic coordinate and proves a swap with no per-dataset knowledge. For features whose true coordinates both stay under 90, fall back to a bounding-box assertion against the dataset’s known extent.
How does the WMS 1.1.1 versus 1.3.0 difference cause swaps?
WMS 1.1.1 always expresses EPSG:4326 bounding boxes as longitude/latitude, while WMS 1.3.0 honours the authority axis order and expects latitude/longitude. A client that bumps the protocol version without reordering its BBOX parameter requests a transposed rectangle, and the server returns tiles for the wrong area — a swap baked into the response rather than the stored data.
Does always_xy=True fix the problem everywhere?
It fixes it at the pyproj transform boundary by forcing longitude/latitude ordering regardless of the CRS’s declared axes, which is the most common leak point. It does not fix data that was already stored transposed, nor a WMS request built with the wrong ordering — those need the extent assertion and the version-aware BBOX builder respectively. Treat always_xy=True as necessary but not sufficient.
Near the equator and prime meridian, both orderings look plausible — how do I catch swaps there?
A bounding-box check weakens when true longitude and latitude occupy overlapping small ranges, so lean on dataset-specific extent and, where available, a control feature with a known location. If a dataset genuinely straddles the origin, add a per-feature consistency check against an authoritative identifier (a parcel or station whose true coordinates you hold) rather than relying on range bounds alone.
Related
- Coordinate Reference System Validation — the parent guide; the datum gate this axis-order check extends.
- Detecting CRS Drift in CI Pipelines — the sibling gate that runs this extent assertion on every pull request.
- Geometry Validity & Topology Checks — rules out a topology fault when a swap breaks spatial predicates.
- Spatial Coverage & Extent Monitoring — where a swapped bounding box first shows as an implausible extent.
- Spatial Data Freshness & Quality Metrics — the measurement program this coordinate-ordering check belongs to.