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
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.
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
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.