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
A correct conversion keeps
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.
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:
- 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.
- 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.
- 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.
- 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.
- 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.
That last point is the one teams skip and the one that matters most. A round-trip test asks whether
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 |
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
- 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. - 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.
- Anti-meridian features. A geometry crossing ±180° can round-trip with an inverted bounding box; measure displacement per vertex, not per bounds.
- 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.
- 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.