Coordinate reference system (CRS) metadata that is assumed rather than asserted lets a generation pipeline reproject, mislabel, or strip a layer’s CRS silently, so the geometries are numerically wrong long before any test that looks at their values.
Part of Scoping Rules & Data Contracts: that page defines the contract every synthetic artifact owes its consumers — schema, nullability, units, CRS; this page hardens the single most silently violated clause, the CRS, by asserting it at each boundary where a layer changes hands instead of trusting whatever metadata happens to ride along.
A generation pipeline is a chain of components — a sampler, a reprojection step, a tessellator, a writer — and every boundary between them is a place where CRS metadata can be lost or quietly changed. Three mechanisms cause the drift, and none of them raises an error on its own:
Missing CRS treated as a default. A GeoDataFrame built from raw coordinate arrays has crs = None. The next component that needs a CRS often assumesEPSG:4326 rather than refusing to proceed, so metre-valued projected coordinates get labelled as degrees and every downstream distance is wrong by orders of magnitude.
Implicit reprojection on join or overlay. When two layers with different CRSs meet in an overlay or spatial join, some code paths reproject one to match the other automatically. The operation succeeds, the result looks plausible, and the output CRS is whichever layer happened to be on the left — a decision no one made deliberately.
Axis-order confusion.EPSG:4326 is formally latitude-longitude, but most tooling stores longitude-latitude. A boundary that swaps the two produces coordinates that are still inside a valid range, so no bounds check fires, yet every point is transposed across the diagonal of the globe.
The common thread is that CRS is carried as advisory metadata the pipeline trusts, when it should be a contract the pipeline asserts. The fix is not a better default; defaults are the disease. The fix is to declare the expected CRS at every boundary, assert the incoming layer matches it, and refuse to guess. A cheap, powerful cross-check is the bounding box: geographic coordinates live in [-180, 180] × [-90, 90], while a projected CRS such as EPSG:6933 produces values in the millions of metres. If a layer declares EPSG:4326 but its coordinates exceed 180, the declaration is a lie and the pipeline should stop. This is the CRS-specific instance of the same fail-loud discipline that governs automated fallbacks for missing spatial attributes: a missing value must trigger an explicit, logged decision, never a silent guess.
A contract gate on every boundary asserts the declared CRS and range before a layer moves on; the lower path shows what an ungated boundary lets through — a null-CRS layer assumed to be geographic and written out as nonsense.
Reproduce the failure in isolation. A layer built from projected metre coordinates but never assigned a CRS gets assumed to be geographic by the next operation, and nothing complains.
import geopandas as gpd
from shapely.geometry import Point
# Points are actually in metres (EPSG:6933), but the CRS was never set.
pts = gpd.GeoDataFrame(geometry=[Point(-8_600_000,5_400_000),
Point(-8_601_000,5_401_000)])print(pts.crs)# None <- the root cause, and it is not an error# A downstream step "helpfully" assumes geographic coordinates:
pts = pts.set_crs("EPSG:4326", allow_override=True)# WRONG, but silentprint(pts.total_bounds)# [-8601000 5400000 -8600000 5401000]# Longitude of -8.6 million "degrees" is absurd, yet no exception was raised.
The bounding box is the tell: values in the millions cannot be degrees. The reproducer confirms both the missing-CRS root cause and the cheap check that catches it.
Centralize the contract in one assertion and call it on the way into and out of every component. The assertion refuses to guess: a None CRS is a hard failure, a mismatched CRS is a hard failure, and coordinates outside the declared CRS’s envelope are a hard failure. This is the discipline the CI/CD integration for spatial data stage relies on to keep drift out of a release.
Step 1 — A single contract assertion. One function encodes the whole clause: exact CRS identity plus a bounding-box sanity check keyed to whether the CRS is geographic or projected.
python
import geopandas as gpd
from pyproj import CRS
classCRSContractError(AssertionError):"""Raised when a layer violates its declared CRS contract."""defassert_crs(gdf: gpd.GeoDataFrame, expected:str, boundary:str)-> gpd.GeoDataFrame:if gdf.crs isNone:raise CRSContractError(f"[{boundary}] layer has no CRS; refusing to assume one")ifnot CRS.from_user_input(gdf.crs).equals(CRS.from_user_input(expected)):raise CRSContractError(f"[{boundary}] CRS {gdf.crs} != contract {expected}")
minx, miny, maxx, maxy = gdf.total_bounds
if CRS.from_user_input(expected).is_geographic:# degrees
ok =(-180<= minx and maxx <=180and-90<= miny and maxy <=90)else:# projected: reject degree-range values
ok =max(abs(minx),abs(maxx),abs(miny),abs(maxy))>180ifnot ok:raise CRSContractError(f"[{boundary}] bbox {gdf.total_bounds} implausible for {expected}")return gdf
Step 2 — Reproject explicitly, never implicitly. A boundary that changes CRS must assert the input CRS, call to_crs deliberately, and assert the output. No operation is allowed to reproject as a side effect.
python
defreproject(gdf: gpd.GeoDataFrame, src:str, dst:str)-> gpd.GeoDataFrame:
assert_crs(gdf, src, boundary="reproject:in")# input must already match the contract
out = gdf.to_crs(dst)# the ONLY place a CRS may changereturn assert_crs(out, dst, boundary="reproject:out")
Step 3 — Wrap every component boundary. Guard the generator’s output, the tessellation input and output, and the writer. Where two layers meet, assert both share the CRS before the overlay so no path can reproject one silently. The tessellation contract is the same one enforced by the polygon tessellation algorithms stage, which assumes its input is already in a metric CRS.
python
deftessellate_stage(points: gpd.GeoDataFrame, work_crs:str="EPSG:6933"):
points = assert_crs(points, work_crs, boundary="tessellate:in")# must be metres
cells = build_voronoi(points)# your tessellator; inherits input CRS
cells = cells.set_crs(work_crs, allow_override=False)# never override a real CRSreturn assert_crs(cells, work_crs, boundary="tessellate:out")defwrite_stage(gdf: gpd.GeoDataFrame, path:str, out_crs:str="EPSG:4326"):
gdf = reproject(gdf, src="EPSG:6933", dst=out_crs)# explicit final reprojection
gdf.to_file(path, driver="GPKG")# GeoPackage persists the CRS
Assert the contract as pytest cases so a regression that drops or swaps a CRS fails the build. The key positive test is that an unlabelled or mislabelled layer is rejected, not silently corrected.
python
import geopandas as gpd, pytest
from shapely.geometry import Point
deftest_crs_contract_rejects_missing_and_mismatched():# 1. A layer with no CRS is refused, not defaulted.
bare = gpd.GeoDataFrame(geometry=[Point(0,0)])with pytest.raises(CRSContractError,match="no CRS"):
assert_crs(bare,"EPSG:4326", boundary="test")# 2. Metre coordinates mislabelled as degrees fail the bbox check.
mislabeled = gpd.GeoDataFrame(geometry=[Point(-8_600_000,5_400_000)],
crs="EPSG:4326")with pytest.raises(CRSContractError,match="implausible"):
assert_crs(mislabeled,"EPSG:4326", boundary="test")# 3. A correct geographic layer passes and round-trips through reprojection.
good = gpd.GeoDataFrame(geometry=[Point(-8.6,43.2)], crs="EPSG:4326")
metres = reproject(good,"EPSG:4326","EPSG:6933")assert metres.crs.to_epsg()==6933assert(reproject(metres,"EPSG:6933","EPSG:4326").geometry.iloc[0].x == pytest.approx(-8.6,abs=1e-6))
The rejection tests are the load-bearing pair: it is not enough that valid data flows through — the gate must actively stop the null-CRS and mislabelled cases the pipeline would otherwise wave past.
Axis order at EPSG:4326. The authority definition of EPSG:4326 is latitude-longitude, but GeoJSON, shapely, and most in-memory tooling use longitude-latitude. A layer read through a driver that honours authority axis order arrives transposed, and because a swapped point often still lands inside [-90, 90] for both axes, the bounding-box check can pass. Assert the expected axis order explicitly with pyproj and, for regions with |lat| < 90 < |lon| impossible under a swap, add a domain-specific bounds check tighter than the global envelope.
Antimeridian-spanning bounding box. A layer that straddles ±180° longitude reports a total_bounds of roughly [-180, ..., 180, ...] — a box that appears to wrap the whole planet even though the data occupies a narrow strip across the Pacific. A naive extent check reads this as valid but a downstream clip against it fails. Detect the wrap (min near −180 and max near +180 with an empty middle) and either split the layer at the seam or shift into a region-local projected CRS before the extent assertion.
UTM zone boundaries. Pipelines that pick a UTM zone from a layer’s centroid silently choose different zones for layers whose centroids fall on opposite sides of a 6°-wide boundary, so two datasets that should share a CRS end up in EPSG:32630 and EPSG:32631. The contract must pin one explicit projected CRS for the whole run — a single equal-area CRS such as EPSG:6933, or one named UTM zone — rather than deriving it per-layer from geometry.
Why not just default a missing CRS to EPSG:4326 and move on?
Because a missing CRS is a signal that something upstream lost metadata, and defaulting hides that failure while producing wrong numbers when the data was actually projected. A degree of longitude is not a metre, so mislabelling metre coordinates as degrees corrupts every distance, area, and join downstream. The contract refuses to guess and forces the upstream producer to declare its CRS explicitly.
Which CRS should the internal working stages use?
Use a metric CRS for any stage that computes distances, areas, or tessellations, because those operations are meaningless in degrees. A global equal-area CRS such as EPSG:6933 works for worldwide data; a single named UTM zone is tighter for a regional dataset. Reproject to EPSG:4326 only at the final write boundary if consumers expect geographic coordinates, and assert the CRS on both sides of that reprojection.
The bounding-box check passes but coordinates still look wrong. What else can drift?
Axis order. EPSG:4326 is formally latitude-longitude while most tooling uses longitude-latitude, and a transposed point frequently still falls inside the valid global range, so the extent check passes. Assert the expected axis order explicitly through pyproj and add a domain-tight bounds check for your region, where a swap would push a coordinate outside the expected local envelope even though it stays inside the global one.