CRS Normalization Strategies for Geospatial Events

Detect the source coordinate reference system at the ingestion boundary, then reproject every incoming geometry to a single canonical EPSG:4326 (WGS 84) projection — using cached, thread-safe pyproj transformers — before it touches any spatial index, routing rule, or store.

This implementation guide is part of Spatial Payload Routing & Parsing, the section covering how GeoJSON and binary geometry payloads are ingested, classified, validated, and forwarded to spatial consumers.


Prerequisites

Confirm your environment meets these baseline requirements before building a normalization stage:


Architecture Overview

Normalization sits between raw ingestion and any spatially aware consumer. It runs as three ordered layers: detect the source CRS, reproject through a cached transformer, then emit a canonical geometry. Anything that cannot be resolved is quarantined rather than guessed at.

CRS Normalization Data Flow A payload enters a detection layer that reads explicit CRS metadata, vendor headers, or coordinate-bounds heuristics. Resolved payloads pass to a reprojection layer backed by a thread-safe transformer cache, then to a canonical emission layer that serializes EPSG:4326 GeoJSON. Payloads with no resolvable CRS branch to a dead-letter quarantine. Raw payload mixed CRS 1 · Detect CRS crs / srid property explicit metadata X-Source-CRS header vendor fallback bounds heuristic > 180 = projected unresolved → quarantine 2 · Reproject transformer cache keyed src→4326 shapely transform all rings, always_xy precision reduce (after) 3 · Emit EPSG:4326 GeoJSON / proto spatial index + routing rules dead-letter queue

Layer 1 — Detect CRS: Resolve the source projection from explicit GeoJSON crs/srid metadata, then a vendor header, then a coordinate-bounds heuristic. Payloads that resolve to nothing branch to quarantine instead of defaulting silently.

Layer 2 — Reproject: A cached pyproj.Transformer keyed by the source→target CRS pair reprojects the whole geometry with shapely.ops.transform, walking every coordinate ring. Precision reduction happens only after the transform.

Layer 3 — Emit: The geometry is serialized as canonical EPSG:4326 GeoJSON (or a binary equivalent) and handed to the spatial index and routing rules with a single, predictable projection.


Step-by-Step Implementation

Step 1 — Detect the Source CRS and Extract Metadata

The first responsibility of the stage is to learn the source projection without trusting a default. Read the GeoJSON crs member, fall back to a srid/epsg property, then a vendor header, then a bounds heuristic. For the full fallback chain and how to keep it off the consumer thread, see Handling mixed CRS payloads in Python event handlers.

python
from __future__ import annotations

from shapely.geometry import shape

CANONICAL_EPSG = 4326  # WGS 84 lon/lat


class CRSUnresolved(Exception):
    """Raised when no detection method can determine the source CRS."""


def detect_source_epsg(payload: dict, headers: dict[str, str] | None = None) -> int:
    """Resolve the source EPSG code from metadata, header, then geometry bounds."""
    headers = headers or {}

    # 1. Explicit GeoJSON 2008-style crs member, e.g. urn:ogc:def:crs:EPSG::32633
    crs_member = payload.get("crs", {}).get("properties", {}).get("name", "")
    if "EPSG::" in crs_member:
        return int(crs_member.rsplit("EPSG::", 1)[1])

    # 2. Vendor property or header
    for candidate in (payload.get("properties", {}).get("source_epsg"),
                      headers.get("X-Source-CRS")):
        if candidate:
            return int(str(candidate).removeprefix("EPSG:"))

    # 3. Bounds heuristic: |lon| <= 180 and |lat| <= 90 implies geographic 4326.
    geom = shape(payload["geometry"])
    minx, miny, maxx, maxy = geom.bounds
    if -180 <= minx <= 180 and -90 <= miny <= 90 and abs(maxx) <= 180 and abs(maxy) <= 90:
        return CANONICAL_EPSG

    raise CRSUnresolved(
        f"Coordinates out of geographic range (bounds={geom.bounds}); "
        "no explicit CRS supplied"
    )

Step 2 — Pre-Transformation Bounds and Topology Validation

Validate before you spend CPU on a transform. Check that coordinates fall within the source CRS’s declared extent and that the topology is repairable, so malformed input fails fast instead of producing silently corrupt output. This is the lightweight gate ahead of the deeper checks in Geometry Validation Pipelines.

python
from shapely.geometry.base import BaseGeometry
from shapely.validation import explain_validity, make_valid


def validate_before_transform(geom: BaseGeometry) -> BaseGeometry:
    """Reject empty geometries and repair invalid topology before reprojection."""
    if geom.is_empty:
        raise ValueError("Empty geometry cannot be normalized")

    if not geom.is_valid:
        # explain_validity surfaces the exact failing ring for DLQ triage.
        reason = explain_validity(geom)
        repaired = make_valid(geom)
        if repaired.is_empty:
            raise ValueError(f"Unrepairable geometry: {reason}")
        return repaired

    return geom

Step 3 — Initialize and Cache Thread-Safe Transformers

Constructing a pyproj.Transformer triggers a PROJ database lookup, which is far too slow to repeat per request. Transformer instances are thread-safe once built, so cache them keyed by the source→target pair behind a lock with a bounded size to prevent unbounded growth in long-running workers.

python
import threading

from pyproj import CRS, Transformer


class TransformerCache:
    """Bounded, thread-safe cache of pyproj transformers keyed by CRS pair."""

    def __init__(self, maxsize: int = 128) -> None:
        self._lock = threading.Lock()
        self._cache: dict[tuple[int, int], Transformer] = {}
        self._maxsize = maxsize

    def get(self, src_epsg: int, dst_epsg: int = CANONICAL_EPSG) -> Transformer:
        key = (src_epsg, dst_epsg)
        with self._lock:
            transformer = self._cache.get(key)
            if transformer is None:
                transformer = Transformer.from_crs(
                    CRS.from_epsg(src_epsg),
                    CRS.from_epsg(dst_epsg),
                    always_xy=True,  # force lon/lat ordering for GeoJSON
                )
                if len(self._cache) >= self._maxsize:
                    # Insertion-ordered dict (Python 3.7+): evict the oldest entry.
                    self._cache.pop(next(iter(self._cache)))
                self._cache[key] = transformer
            return transformer


TRANSFORMERS = TransformerCache()

Step 4 — Reproject Geometry While Preserving Topology

Apply the cached transformer with shapely.ops.transform, which walks every coordinate in every ring — so MultiPolygon and GeometryCollection features are reprojected in full, not just their bounding boxes. Reduce precision only after the transform completes.

python
from shapely.geometry import mapping
from shapely.geometry.base import BaseGeometry
from shapely.ops import transform as shapely_transform
from shapely import set_precision


def reproject_to_canonical(geom: BaseGeometry, src_epsg: int) -> BaseGeometry:
    """Reproject any geometry to EPSG:4326, snapping precision afterwards."""
    if src_epsg == CANONICAL_EPSG:
        out = geom
    else:
        transformer = TRANSFORMERS.get(src_epsg, CANONICAL_EPSG)
        out = shapely_transform(transformer.transform, geom)

    # Grid-snap AFTER reprojection (~1.1 mm at the equator); never before.
    out = set_precision(out, grid_size=1e-8)
    if not out.is_valid:
        raise ValueError("Geometry became invalid after precision reduction")
    return out


def normalize_feature(payload: dict, headers: dict[str, str] | None = None) -> dict:
    """End-to-end: detect, validate, reproject, and return a canonical Feature."""
    src_epsg = detect_source_epsg(payload, headers)
    geom = validate_before_transform(shape(payload["geometry"]))
    geom = reproject_to_canonical(geom, src_epsg)
    return {
        "type": "Feature",
        "geometry": mapping(geom),
        "properties": {
            **payload.get("properties", {}),
            "crs": "EPSG:4326",
            "source_epsg": src_epsg,
            "bbox": list(geom.bounds),
        },
    }

Step 5 — Serialize the Canonical Geometry for Downstream Routing

Validate the normalized output against an explicit schema with Pydantic v2 before it leaves the stage, then serialize it for the consumer. If a downstream service needs a compact wire format, the canonical EPSG:4326 feature feeds directly into the GeoJSON to Protobuf Mapping path without further projection.

python
from pydantic import BaseModel, Field, model_validator

_ALLOWED = {"Point", "LineString", "Polygon", "MultiPoint",
            "MultiLineString", "MultiPolygon", "GeometryCollection"}


class CanonicalFeature(BaseModel):
    type: str = Field(pattern="^Feature$")
    geometry: dict
    properties: dict

    @model_validator(mode="after")
    def check(self) -> "CanonicalFeature":
        if self.geometry.get("type") not in _ALLOWED:
            raise ValueError(f"Unsupported geometry type: {self.geometry.get('type')}")
        if self.properties.get("crs") != "EPSG:4326":
            raise ValueError("Feature is not normalized to EPSG:4326")
        return self


def serialize_for_routing(feature: dict) -> str:
    """Validate the canonical feature and emit RFC 7946 GeoJSON for the consumer."""
    import json
    validated = CanonicalFeature.model_validate(feature)
    return json.dumps(validated.model_dump())

When payloads carry temporal metadata, normalize timestamps to UTC alongside the spatial projection so that spatiotemporal joins downstream are never skewed by a mixed timezone and a mixed CRS at once.


Spatial Validation and Error Handling

Reprojection can turn a valid source geometry into an invalid target one — narrow slivers collapse, near-coincident vertices merge, and ring orientation can flip relative to RFC 7946, which requires exterior rings to be counter-clockwise. Re-check validity and orientation after the transform, not only before it:

python
from shapely.geometry import Polygon, MultiPolygon
from shapely.geometry.polygon import orient
from shapely.validation import explain_validity


def enforce_post_transform_invariants(geom):
    """Re-validate and fix ring orientation after reprojection."""
    if not geom.is_valid:
        raise ValueError(f"Invalid after transform: {explain_validity(geom)}")

    # RFC 7946 §3.1.6: exterior rings CCW, interior rings CW (sign=1.0 in Shapely).
    if isinstance(geom, Polygon):
        return orient(geom, sign=1.0)
    if isinstance(geom, MultiPolygon):
        return MultiPolygon([orient(p, sign=1.0) for p in geom.geoms])
    return geom

On failure, route the payload — together with the detected source_epsg, the validity explanation, and the raw bytes — to a dead-letter queue rather than retrying a deterministic transform that will only fail again. A geometry that is invalid after transform is a data pathology, not a transient fault. Where retries are warranted (transient PROJ data or database errors), they must not reprocess features already persisted, which is why normalization pairs with the at-least-once delivery pattern.


Retry, Backoff, and Delivery Guarantees

Distinguish transient failures (PROJ grid-shift download timeouts, missing transformation pipelines on cold start) from permanent ones (unresolvable CRS, post-transform invalidity). Retry only the transient class, with exponential backoff and jitter:

python
import asyncio
import random


async def normalize_with_retry(
    payload: dict,
    headers: dict[str, str] | None = None,
    max_attempts: int = 4,
    base_delay: float = 0.5,
    max_delay: float = 15.0,
) -> dict | None:
    """Retry normalization for transient errors only; quarantine the rest."""
    for attempt in range(1, max_attempts + 1):
        try:
            return await asyncio.to_thread(normalize_feature, payload, headers)
        except (CRSUnresolved, ValueError):
            # Deterministic failure — no retry will change the outcome.
            await send_to_dlq(payload, reason="permanent")
            return None
        except OSError as exc:
            # e.g. PROJ network resource fetch failed — worth a retry.
            if attempt == max_attempts:
                await send_to_dlq(payload, reason=f"transient: {exc}")
                return None
            delay = min(base_delay * 2 ** (attempt - 1), max_delay)
            await asyncio.sleep(delay + random.uniform(0, delay * 0.2))
    return None

At-least-once vs exactly-once: Reprojection itself is deterministic, so re-delivering the same payload always yields the same canonical geometry. That makes at-least-once safe for the transform, but the write still needs deduplication. Derive an idempotency key from the normalized geometry so a re-delivery does not duplicate rows in your spatial store, following Event Key Generation for Spatial Data and the cache check in Cache-Backed Idempotency Checks.


Verification

Confirm the round trip end to end: a UTM-projected polygon must come out as a valid EPSG:4326 feature whose coordinates are back inside geographic range. Run this with pytest:

python
import pytest
from shapely.geometry import shape

# A square ~1 km on a side in UTM Zone 33N (EPSG:32633), near Rome.
UTM_FEATURE = {
    "type": "Feature",
    "crs": {"properties": {"name": "urn:ogc:def:crs:EPSG::32633"}},
    "geometry": {
        "type": "Polygon",
        "coordinates": [[
            [290000, 4640000],
            [291000, 4640000],
            [291000, 4641000],
            [290000, 4641000],
            [290000, 4640000],
        ]],
    },
    "properties": {"name": "utm-test"},
}


def test_utm_normalizes_to_wgs84():
    result = normalize_feature(UTM_FEATURE)

    assert result["properties"]["crs"] == "EPSG:4326"
    assert result["properties"]["source_epsg"] == 32633

    geom = shape(result["geometry"])
    assert geom.is_valid

    minx, miny, maxx, maxy = geom.bounds
    # Output must be inside geographic range and near Rome (~12.5E, 41.9N).
    assert 12 < minx < 13 and 41 < miny < 42
    assert -180 <= minx <= 180 and -90 <= miny <= 90


def test_unresolvable_crs_raises():
    bad = {"type": "Feature", "geometry": {
        "type": "Point", "coordinates": [500000, 4640000]}, "properties": {}}
    with pytest.raises(CRSUnresolved):
        normalize_feature(bad)  # projected coords, no CRS declared

A passing run confirms three things at once: detection read the embedded EPSG code, the transformer reprojected the full ring, and the output survived post-transform validation.


Troubleshooting

Symptom Likely spatial cause Fix
Features land in the wrong hemisphere Axis order swapped — lat/lon emitted instead of lon/lat Set always_xy=True on every Transformer.from_crs call
Coordinates look like [500000, 4640000] after normalization Source was a projected CRS (meters) treated as EPSG:4326 Read the crs member or X-Source-CRS header; reject payloads with no resolvable CRS
Geometry valid before, invalid after transform Sliver collapse or vertex merge from projection distortion Re-run make_valid/orient after the transform; widen grid_size only as a last resort
First request per CRS pair is slow, rest are fast PROJ database lookup on every transformer construction Cache transformers keyed by (src, dst) EPSG pair; pre-warm common pairs at startup
Polygons render with holes filled in Ring orientation flipped relative to RFC 7946 after reprojection Apply shapely.geometry.polygon.orient(geom, sign=1.0) post-transform
Memory grows in long-running workers Unbounded transformer cache accumulating rare CRS pairs Bound the cache maxsize and evict the oldest entry on overflow
CRSError: Invalid projection on cold start Missing PROJ grid-shift files in the container image Bundle PROJ data or set PROJ_NETWORK=ON; retry transient fetch failures with backoff

FAQ

Why normalize to EPSG:4326 instead of EPSG:3857?

EPSG:4326 (WGS 84 lat/lon) is the canonical interchange CRS mandated by RFC 7946 for GeoJSON and the safe storage projection for spatial indexes. EPSG:3857 (Web Mercator) distorts area and distance increasingly away from the equator, so reserve it for the render step of a tile pipeline — never as the canonical storage CRS.

How do I detect the CRS when the payload has no crs property?

Apply a fallback resolution chain: check an explicit crs or srid property, then a vendor header such as X-Source-CRS, then a heuristic on the coordinate bounds — values outside the ±180 / ±90 range almost always indicate a projected system measured in meters. If nothing resolves, quarantine the payload to a dead-letter queue rather than guessing. The full chain is in Handling mixed CRS payloads in Python event handlers.

Why must I set always_xy=True on a pyproj Transformer?

PROJ 6+ honours each CRS’s authority-defined axis order, which for EPSG:4326 is latitude-first. GeoJSON coordinates are longitude-first. Passing always_xy=True forces lon/lat ordering on both ends, so coordinates do not silently swap and place features in the wrong location.

Should I round coordinates before or after reprojection?

Always round or grid-snap after reprojection. Rounding source coordinates introduces error that the transform amplifies and can collapse adjacent vertices into invalid self-touching rings. Apply precision reduction with shapely.set_precision as the final step before serialization, then re-check validity.


Explore this section