Validating Coordinate Systems Before ETL
A CRS declaration is a claim, and claims should be checked before anything expensive depends on them. Three cheap tests catch nearly every projection failure that reaches production: the CRS must be declared at all, the coordinate ranges must fall inside that CRS’s own area of use, and the axis order must match what the format’s convention says it should be. All three read a bounding box and some metadata, all three run in milliseconds, and together they turn the most damaging class of spatial bug into a rejected delivery.
When to Use This Pattern
- Data arrives from outside your control — a partner, a portal, a public download — which is where wrong declarations come from.
- The pipeline reprojects, so a wrong source CRS silently produces geometry in the wrong place rather than an error.
- Multiple formats are in play. Shapefile, GeoPackage, WFS and GeoJSON have different conventions about axis order and about what “no CRS” means.
- Data has previously turned up in the wrong hemisphere, which is the classic symptom and almost always an axis-order or declaration problem.
Complete Working Example
The validator returns a structured verdict rather than raising, so the caller can quarantine a delivery, reject it, or accept it with a warning according to policy.
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Optional
import fiona
from pyproj import CRS, Transformer
class CrsVerdict(str, Enum):
OK = "ok"
UNDECLARED = "undeclared"
OUT_OF_AREA = "out_of_area"
AXIS_SUSPECT = "axis_suspect"
OUTSIDE_EXPECTED = "outside_expected"
@dataclass(frozen=True)
class CrsCheck:
verdict: CrsVerdict
declared: Optional[str]
bounds: Optional[tuple[float, float, float, float]]
detail: str
def area_bounds_in_crs(crs: CRS) -> Optional[tuple[float, float, float, float]]:
"""The CRS's own area of use, expressed in the CRS's own units."""
area = crs.area_of_use
if area is None:
return None
if crs.is_geographic:
return (area.west, area.south, area.east, area.north)
to_crs = Transformer.from_crs(CRS.from_epsg(4326), crs, always_xy=True)
xs, ys = zip(*(to_crs.transform(x, y) for x, y in (
(area.west, area.south), (area.east, area.south),
(area.west, area.north), (area.east, area.north),
)))
return (min(xs), min(ys), max(xs), max(ys))
def check_crs(
path: Path,
layer: str = "features",
expected_extent_4326: Optional[tuple[float, float, float, float]] = None,
) -> CrsCheck:
with fiona.open(path, layer=layer) as src:
if not src.crs:
return CrsCheck(CrsVerdict.UNDECLARED, None, None,
"no CRS on the layer — a .prj or srs entry is missing")
crs = CRS.from_user_input(src.crs)
bounds = src.bounds # (minx, miny, maxx, maxy), no feature scan
declared = crs.to_string()
# 1. Axis-order sanity, only meaningful for geographic CRSs. Latitude cannot
# exceed 90, so a "y" beyond that is longitude in the wrong slot.
if crs.is_geographic and (abs(bounds[1]) > 90.5 or abs(bounds[3]) > 90.5):
return CrsCheck(CrsVerdict.AXIS_SUSPECT, declared, bounds,
f"latitude range {bounds[1]:.2f}..{bounds[3]:.2f} exceeds ±90 — "
"the axes are almost certainly swapped")
# 2. Inside the CRS's own declared area of use.
area = area_bounds_in_crs(crs)
if area is not None:
pad = 0.02 * max(area[2] - area[0], area[3] - area[1]) # tolerate edge cases
if not (area[0] - pad <= bounds[0] and bounds[2] <= area[2] + pad
and area[1] - pad <= bounds[1] and bounds[3] <= area[3] + pad):
return CrsCheck(CrsVerdict.OUT_OF_AREA, declared, bounds,
f"bounds {bounds} fall outside the area of use for {declared}")
# 3. Inside the extent this DELIVERY was supposed to cover, if we know it.
if expected_extent_4326 is not None:
to_wgs84 = Transformer.from_crs(crs, CRS.from_epsg(4326), always_xy=True)
x0, y0 = to_wgs84.transform(bounds[0], bounds[1])
x1, y1 = to_wgs84.transform(bounds[2], bounds[3])
ex = expected_extent_4326
if not (ex[0] <= x0 and x1 <= ex[2] and ex[1] <= y0 and y1 <= ex[3]):
return CrsCheck(CrsVerdict.OUTSIDE_EXPECTED, declared, bounds,
f"data at ({x0:.3f},{y0:.3f})..({x1:.3f},{y1:.3f}) is outside "
f"the expected delivery extent {ex}")
return CrsCheck(CrsVerdict.OK, declared, bounds, "declaration consistent with the data")
The verdicts are deliberately not a boolean, because the right response differs sharply between them. UNDECLARED is a broken delivery and should be rejected outright — there is nothing to check against, and proceeding means guessing. AXIS_SUSPECT with an impossible latitude is equally decisive; the same verdict on plausible values is a warning that a human should look at. OUT_OF_AREA usually means the wrong CRS was declared and the data is intact, so quarantining the delivery and asking the publisher is the productive path. OUTSIDE_EXPECTED most often means the expectation is wrong — a delivery that legitimately grew to cover a new region — and it should page nobody while still appearing in the run’s summary.
Parameter & Option Reference
| Parameter | Type | Default | Spatial notes |
|---|---|---|---|
expected_extent_4326 |
tuple | None |
The region this delivery claims to cover. Without it, a correctly-projected dataset from the wrong country passes. |
| area-of-use padding | 2% |
— | Real data legitimately touches the edge of a CRS’s declared region; a hard boundary produces false rejections. |
| axis threshold | ±90.5 |
— | Latitude cannot exceed 90. The half-degree of slack absorbs rounding in coarse bounding boxes. |
src.bounds |
— | — | Fiona reports the layer’s bounding box from the header where the driver supports it — no feature scan. |
always_xy=True |
bool |
required | Forces longitude-first ordering in pyproj, so the transform’s own axis convention cannot add a second bug. |
| verdict granularity | 5 values | — | Distinct verdicts let policy differ per case: undeclared rejects, out-of-area quarantines, outside-expected warns. |
Verification & Testing
Each verdict needs a fixture, and the fixtures are small enough to build in the test itself.
def test_undeclared_crs_is_caught(tmp_path) -> None:
path = write_layer(tmp_path, crs=None, coords=[(10.7, 59.9)])
assert check_crs(path).verdict is CrsVerdict.UNDECLARED
def test_swapped_axes_are_caught(tmp_path) -> None:
"""Oslo written as (lat, lon) instead of (lon, lat)."""
path = write_layer(tmp_path, crs="EPSG:4326", coords=[(59.9, 10.7)])
check = check_crs(path)
# 59.9 as a longitude is legal, so this one only trips on a wider dataset…
path_wide = write_layer(tmp_path, crs="EPSG:4326", coords=[(59.9, 110.7)])
assert check_crs(path_wide).verdict is CrsVerdict.AXIS_SUSPECT
def test_utm_coordinates_declared_as_wgs84(tmp_path) -> None:
path = write_layer(tmp_path, crs="EPSG:4326", coords=[(598_000, 6_643_000)])
assert check_crs(path).verdict is CrsVerdict.AXIS_SUSPECT
def test_right_projection_wrong_country(tmp_path) -> None:
path = write_layer(tmp_path, crs="EPSG:25832", coords=[(700_000, 5_200_000)])
norway = (4.0, 57.0, 32.0, 72.0)
assert check_crs(path, expected_extent_4326=norway).verdict is CrsVerdict.OUTSIDE_EXPECTED
The command-line equivalents are worth knowing because they are what you reach for at 02:00, before writing any code:
# What does the file claim, and where does it actually sit?
ogrinfo -so -al delivery.gpkg | grep -E "Extent|PROJCRS|GEOGCRS|ID\["
# For a raster, the same two facts:
gdalinfo delivery.tif | grep -E "Corner Coordinates|Upper Left|PROJCRS" -A 4
Common Pitfalls
- Trusting the
.prjbecause it exists. A present projection file means someone wrote one, not that it is right. Re-exported data frequently keeps the old.prjalongside new coordinates. - Testing only near the equator. Around (0, 0) a swapped pair is still plausible, so an axis test that passes on a fixture in Ghana proves nothing. Use fixtures at high latitude, where a swap becomes impossible.
- Comparing against a hand-written bounding box. The CRS knows its own area of use; a hard-coded box goes stale and is wrong for every CRS but the one it was written for. Use
pyproj. - Assuming
always_xyeverywhere. Fiona,pyprojand WFS 2.0.0 each have their own axis convention, and the authority order for many national CRSs is northing-first. Being explicit at each boundary is the only reliable approach. - Reprojecting to “fix” a failed check. A wrong declaration reprojected is wrong data in a new CRS. The correct response is to reject or quarantine and ask the publisher, never to transform on the assumption that you know what they meant.
Frequently Asked Questions
What if the source genuinely has no CRS?
Then the delivery is incomplete and should be rejected. Assuming a CRS is guessing, and a guess that is right nine times out of ten produces a silent tenth failure that nobody can trace. Where a publisher consistently omits it and the CRS is genuinely known from the contract, encode that in configuration as an explicit override with a comment — visible, deliberate, and reviewable.
Is the axis check reliable enough to reject on?
It is reliable enough to reject when latitude exceeds ±90, because that is arithmetically impossible. It is not reliable for data near the equator, where both orderings are plausible. Treat “impossible” as a rejection and “suspect” as a quarantine, which is why the verdict enum has more than two values.
Should this run before or after the download?
Before anything expensive, and ideally on the metadata rather than the file. A STAC item or a WFS GetCapabilities document declares the CRS and often the extent, so the check can run before a byte of data moves. For a file already on disk, it costs a header read — either way it belongs at the very front of the chain.
How does this interact with reprojection later in the chain?
It is what makes reprojection safe. Once the declared CRS has been checked against the data, the transform is a mechanical operation with a known input. Without the check, the transform is an amplifier: it takes a wrong assumption and produces confidently wrong coordinates that every subsequent step accepts. See building ETL chains for vector data for where the transform belongs.
Related
- Spatial validation & sync tasks — the boundary this check sits on
- Repairing invalid geometries before load — the geometry half of the same boundary
- Building ETL chains for vector data — the contract this check establishes
- Storing failed geometries in a PostGIS dead-letter queue — where a quarantined delivery goes