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.

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

  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.