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")
Three checks, narrowing in turnThe axis check rejects impossible latitudes. The area-of-use check rejects coordinates outside the CRS's own valid region. The expected-extent check rejects data outside the region this delivery was supposed to cover.bounding boxone header read1 · axis sanity|lat| ≤ 902 · area of usefrom pyproj3 · extentacceptswapped axeswrong projectionright projection, wrong region
Each check rejects a different mistake, and the third one — right projection, wrong area — is the one that only a delivery-specific extent can catch.

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.

Verdict to actionUndeclared rejects the delivery. Impossible axis values reject. Suspect axis values quarantine for review. Out of area quarantines and notifies the publisher. Outside the expected extent is recorded in the run summary only.verdictactionUNDECLAREDreject — nothing can be checked againstAXIS_SUSPECT · |lat| > 90reject — arithmetically impossibleAXIS_SUSPECT · plausiblequarantine — a person decidesOUT_OF_AREAquarantine and ask the publisherOUTSIDE_EXPECTEDrecord in the run summary; often our expectation is stale
Collapsing these to pass/fail forces the last row to behave like the first, which is how a validator ends up disabled after its third false alarm.

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
Ranges that cannot belong to the declared CRSWGS84 coordinates occupy a small range near the origin. UTM zone 32 coordinates are in the hundreds of thousands and millions. A value in the UTM range declared as WGS84 is impossible.declaredobserved boundsverdictEPSG:432610.6 .. 11.2, 59.8 .. 60.1okEPSG:4326598000 .. 612000, 6.64e6 ..impossible — UTM metresEPSG:432659.8 .. 60.1, 10.6 .. 11.2legal but suspect — swappedEPSG:25832700000 .. 705000, 5.2e6 ..valid CRS, wrong countryOnly the second row is impossible from the numbers alone. The third and fourth need conventions and expectations.
Row three is why the axis check is a warning rather than a rejection: swapped coordinates near the equator are indistinguishable from correct ones without knowing where the data should be.

Common Pitfalls

  • Trusting the .prj because it exists. A present projection file means someone wrote one, not that it is right. Re-exported data frequently keeps the old .prj alongside 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_xy everywhere. Fiona, pyproj and 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.

Spatial Validation & Sync Tasks