Detecting CRS Drift Across Format Conversions

Every time spatial data changes format — GeoJSON to Shapefile, Shapefile to GeoPackage, GeoPackage to PostGIS — the coordinate reference system is a place where correctness silently leaks: an axis-order swap, a dropped or defaulted SRID, or a datum-grid difference can move features by metres while every geometry still validates. This guide sits beneath cross-format parity testing and shows how to detect that drift with a round-trip test that bounds the displacement a conversion is allowed to introduce. The failure it prevents is the worst kind — data that passes validity and schema checks but renders in the wrong place — because CRS drift changes coordinates, not structure.

Why CRS drift happens on conversion

Formats disagree about how they carry a CRS. GeoJSON is defined to be in WGS84 longitude/latitude, so writing a projected layer to GeoJSON without reprojection strands the coordinates with no CRS to interpret them. Shapefiles carry the CRS in a sidecar .prj that some tools ignore or write imprecisely. GeoPackage and PostGIS store an SRID, but a conversion that forgets to set it defaults to an unknown or a wrong authority. On top of that, a datum transform applied during conversion depends on the PROJ grid version, so two conversions with different grids differ. The result is a coordinate displacement dd between the original point pp and its round-tripped image pp':

d=pp2d = \lVert p' - p \rVert_2

A correct conversion keeps dd below a CRS-appropriate tolerance τ\tau; drift is any d>τd > \tau.

Four ways a CRS goes missing

“CRS drift” bundles four distinct events, and only one of them moves coordinates at all. Separating them is what lets a failure message say something useful, because the remedy differs completely.

Four CRS failures, only one of which moves a coordinate Four cards. Loss of declaration: coordinates unchanged, no CRS recorded in the artefact, detected by asserting the CRS is present and equal to the expected authority code after reading back. Substitution: coordinates unchanged but a default definition such as CRS84 has replaced the real one, detected by comparing the authority code rather than merely checking that a CRS exists. Axis-order swap: the same two numbers written in the opposite order, detected by asserting a known reference point lands where expected rather than by comparing sets of values. Reprojection drift: coordinates genuinely transformed and displaced, the only case where a numeric tolerance is the right instrument. A footer notes that the first three are contract failures that no tolerance can express. 1 · Declaration lost coordinates: unchanged CRS field: empty DETECT assert crs is not None after reading back next reader will guess 2 · Substituted coordinates: unchanged CRS field: a default DETECT compare to_epsg(), not merely “a CRS exists” right numbers, wrong system 3 · Axis order swapped coordinates: same values written in the other order DETECT assert a known point lands where it should a value check passes 4 · Real drift coordinates: transformed and genuinely displaced DETECT bound the displacement against a budget the only tolerance case The first three are contract failures — no numeric tolerance can express them, and a suite that only measures displacement will report all three as passing. Assert the identity and the geometry separately, and in that order.

The second case is the one that costs most in practice. A driver that writes a default definition rather than nothing produces an artefact that looks complete: it has a CRS, it opens without warnings, every tool downstream accepts it. The data is simply attributed to the wrong system, and the error surfaces weeks later as a systematic offset that someone blames on a datum. Asserting the authority code — not merely that a CRS is present — costs one extra line and catches it at the source.

Drift-source reference

Conversion hazard Mechanism Symptom
Axis-order swap lat/lon vs lon/lat convention Features mirrored across the diagonal
Dropped SRID format defaults CRS Layer renders at null island or wrong zone
GeoJSON not WGS84 projected coords written as degrees Coordinates in the thousands, off-planet
Datum-grid mismatch different PROJ grid version Sub-metre to metre systematic shift
Imprecise .prj rounded WKT parameters Small consistent offset

Step-by-step implementation

The pattern targets GeoPandas 0.14+, pyproj and pytest 7+, and asserts a round trip stays within tolerance.

Step 1 — Fix a reference point in a known CRS

import geopandas as gpd
from shapely.geometry import Point

# A survey monument in UTM 32N (metric), SRID 25832
ref = gpd.GeoDataFrame(
    {"id": [1]}, geometry=[Point(500000, 5649776)], crs="EPSG:25832"
)

Step 2 — Round-trip through the target format

def roundtrip(gdf: gpd.GeoDataFrame, path: str, driver: str) -> gpd.GeoDataFrame:
    gdf.to_file(path, driver=driver)      # write
    back = gpd.read_file(path)            # read back
    return back.to_crs(gdf.crs)           # normalize to the original CRS

Step 3 — Bound the displacement

def max_displacement(a: gpd.GeoDataFrame, b: gpd.GeoDataFrame) -> float:
    a_m, b_m = a.to_crs(25832), b.to_crs(25832)   # measure in metres
    return a_m.geometry.distance(b_m.geometry, align=False).max()

Step 4 — Assert the SRID survived, not just the coordinates

def test_geopackage_roundtrip_preserves_crs(tmp_path):
    out = roundtrip(ref, tmp_path / "p.gpkg", "GPKG")
    assert out.crs.to_epsg() == 25832, f"SRID drifted to {out.crs.to_epsg()}"
    assert max_displacement(ref, out) <= 0.01     # 1 cm budget

Checking the SRID and the displacement matters because a layer can round-trip to correct coordinates but lose its declared SRID, which then corrupts the next conversion — the same silent-SRID hazard flagged in spatial assertion types.

Choosing the reference points that actually catch drift

A round-trip test is only as good as the points it round-trips, and a single centroid is close to useless. Projection error is not uniform: it is smallest at the projection’s origin and grows towards the edges of the valid area, so a check anchored at the middle of a zone can pass while data at the zone boundary is metres out.

A reference set that reliably catches drift has five members, chosen for what each one exercises:

  1. The projection origin — the false easting and northing point. Drift here is close to zero for any correct transform, so a failure means something structurally wrong rather than a precision issue.
  2. A point near the edge of the valid area — where scale distortion is greatest and where an incorrect zone or an approximate transform shows up first.
  3. A point just outside the valid area — the transform should either fail loudly or return infinity; silently returning a plausible-looking coordinate is itself a defect worth catching.
  4. A point on a zone or datum boundary, if the data crosses one — this is where PROJ may select a different operation for two neighbouring features, producing a discontinuity in the middle of a dataset.
  5. A known monument with a published coordinate in both systems — the only member of the set that validates the transform against external truth rather than against its own inverse.
Five reference points and what each one detects A rectangle represents the valid area of a projection zone, with a dashed line marking a zone boundary at its right edge. Point one sits at the centre, marked as the projection origin with near-zero expected residual, detecting only structural failures. Point two sits near the top-right inside the rectangle, marked as maximum scale distortion, detecting a wrong zone or an approximate transform. Point three sits outside the rectangle and is annotated that the transform must fail loudly rather than return a plausible coordinate. Point four sits on the dashed zone boundary and detects PROJ selecting different operations for adjacent features. Point five, drawn distinctly, is a monument with published coordinates in both systems, and is annotated as the only point validating against external truth. valid area of the zone zone boundary 1 origin residual ≈ 0 · structural errors only 2 near the edge max distortion · wrong zone shows here 3 outside must fail loudly, not return a plausible value 4 on the boundary catches operation switching mid-dataset 5 monument published in both systems the only external truth A round trip validates the transform against its own inverse, so two compensating errors cancel and the check passes. Only point five breaks that symmetry — which is why a suite with no externally-published coordinate can be green while the whole dataset sits a metre from where it belongs.

That last point is the one teams skip and the one that matters most. A round-trip test asks whether T1(T(p))T^{-1}(T(p)) returns pp, and the answer is yes for any pair of mutually consistent transforms — including a pair that are both wrong in the same way. If the pipeline selects an operation that omits a datum grid, the forward and inverse both omit it, the residual is essentially zero, and the round-trip test passes while every coordinate sits a metre from truth. Only a coordinate whose value is known independently detects that, which is why one published monument is worth more than a thousand generated points.

Verification pattern

Run the round-trip across every format your pipeline touches and confirm all stay within budget. A CLI probe of the written file’s CRS catches a dropped SRID before the assertion even runs.

python -c "import geopandas as gpd; print(gpd.read_file('p.gpkg').crs)"
# Expect EPSG:25832, not None or a defaulted 4326

Making the check reproducible across runners

A drift check that gives different answers on two machines is worse than none, because the disagreement is read as flakiness and the check gets retried away. Three things have to be pinned, and only the first is obvious.

The PROJ data package version, which supplies the EPSG database and the grid shift files. A transform that resolves to a high-accuracy operation on a machine with the full grid package resolves to a coarser fallback on one without it, and the two differ by exactly the amount the grid was correcting for. Record the version in the log line; install the package explicitly in the image rather than relying on whatever the base layer happened to ship.

The operation itself, not just the endpoints. Asking for a transform between two CRSs lets PROJ choose among candidate operations by its own ranking, which can change between releases. Where the accuracy matters, select the operation explicitly and assert on its reported accuracy, so an upgrade that would silently substitute a different path fails instead.

Axis-order handling at construction. Whether a transformer yields easting-northing or northing-easting depends on the authority definition unless you say otherwise, and the two are indistinguishable when a test point happens to be near the diagonal. Construct with explicit axis ordering and pick reference points that are unambiguous — never a point whose two coordinates are numerically close.

What is unpinned Symptom on another machine Fix
PROJ data version Constant offset of 0.1–2 m Install and record the grid package version
Operation selection Offset appears after an upgrade Select the operation by name; assert accuracy
Axis order default Coordinates mirrored, or nothing at all Construct with explicit ordering; avoid near-diagonal points
Grid file availability Works locally, drifts in CI Fail the run when a required grid is absent
Two compensating errors give a zero round-trip residual A cycle of three states. The source coordinate is transformed forward by an operation missing its datum grid, arriving one metre from the true projected position; this intermediate value is what gets written to the artefact. The inverse transform, missing the same grid, moves it back by the same vector, returning exactly the source coordinate. The round-trip residual is zero, so a round-trip assertion passes. Alongside, a published monument coordinate is compared directly against the intermediate value and reveals the one-metre offset, because it is the only comparison that does not use the transform's own inverse as its reference. source p geographic T(p) — written to the file 1 m from truth T⁻¹(T(p)) = p residual = 0 grid omitted → +1 m grid omitted → −1 m the round-trip assertion passes — the errors cancelled published monument coordinate compares T(p) against external truth → 1 m detected Any assertion that uses a transform’s own inverse as its reference is blind to errors the inverse shares — which is most of the interesting ones.

The last row deserves a dedicated assertion rather than a comment. Checking at start-up that the specific grid files the pipeline depends on are present, and failing loudly if they are not, converts the most common cause of environment-dependent drift into a clear message at the beginning of the run instead of a puzzling numeric difference at the end of it.

Failure modes and edge cases

  1. GeoJSON as a projected store. Writing a UTM layer to GeoJSON without to_crs(4326) produces coordinates GeoJSON readers misinterpret; always reproject to WGS84 for GeoJSON.
  2. Axis-order swap. Some drivers honour the CRS’s declared axis order (lat/lon) and others assume lon/lat; a swap mirrors features — test a point off the diagonal so a swap is detectable.
  3. Anti-meridian features. A geometry crossing ±180° can round-trip with an inverted bounding box; measure displacement per vertex, not per bounds.
  4. Datum-grid version. Two runs with different PROJ grids shift by up to a metre with no code change; pin the grid, per containerized GIS test runtimes.
  5. Symmetric point. A reference point at the CRS origin or on the axis of symmetry hides an axis swap; choose an asymmetric coordinate.

Conclusion

CRS drift across format conversions is caught by a round-trip test that bounds coordinate displacement and asserts the SRID survived, measured in metres against a CRS-appropriate tolerance. Because drift changes position without changing structure, this is the check that stops correctly-shaped data from landing in the wrong place. For the wider parity context, return to cross-format parity testing.