Handling Mixed CRS Payloads in Python Webhooks

To handle mixed CRS payloads in a Python event handler, resolve a source CRS for every incoming geometry — from an explicit field, a GeoJSON crs member, or coordinate-magnitude inference — then transform it to a single canonical CRS (EPSG:4326) with a cached pyproj.Transformer before the payload reaches any consumer; route anything you cannot resolve to a quarantine queue instead of guessing.

This page is a focused how-to within CRS Normalization Strategies for Geospatial Events, which sits under the broader domain of Spatial Payload Routing & Parsing. Read those for the surrounding architecture; this page resolves the single problem of reconciling many input coordinate systems inside one handler.

When to use this pattern

Reach for an inline normalization step at the edge of your handler — rather than fixing coordinates downstream — when:

  • Your sources disagree on CRS. IoT trackers emit EPSG:4326, CAD and survey exports use local projected grids (state plane, UTM zones, national grids), and SaaS integrations frequently default to web-mercator EPSG:3857 or omit the CRS entirely.
  • Downstream consumers assume one CRS. Spatial indexes, PostGIS columns with a fixed SRID, tile builders, and routing engines silently corrupt results when fed coordinates in the wrong system. Normalizing once at ingestion is cheaper than auditing every consumer.
  • You must keep an audit trail. Quarantine-and-log beats best-effort guessing when a wrong transform would write bad geometry into a system of record that is expensive to repair.

If every source already agrees on EPSG:4326, skip this and validate topology only, as covered in the Geometry Validation Pipelines.

How normalization flows through the handler

The handler runs as a deterministic pipeline: resolve the source CRS, build (or reuse) a transformer keyed by the source/target EPSG pair, transform the geometry, re-validate topology after the transform, then route the clean payload onward or quarantine the failure.

Mixed-CRS normalization flow A spatial webhook payload enters a resolver that determines its source CRS from an explicit field, a GeoJSON crs member, or coordinate-magnitude inference. A cached transformer keyed by source and target EPSG codes converts the geometry to EPSG:4326. Topology is re-validated after the transform; clean payloads are routed downstream and unresolvable CRS or invalid geometry is sent to a quarantine queue. Resolve CRS explicit field GeoJSON crs member magnitude inference Cached transformer key: (src, EPSG:4326) always_xy=True lru_cache reuse Transform to EPSG:4326 all geometry types shapely.ops.transform Re-validate topology after datum shift make_valid if needed Route downstream broker / spatial DB Quarantine DLQ + original CRS valid unresolved payload in (mixed CRS)

Complete runnable handler

The example below is self-contained: a FastAPI endpoint with Pydantic validation, CRS resolution with magnitude-based inference, a cached pyproj.Transformer, post-transform re-validation, and explicit quarantine routing. It targets EPSG:4326 because RFC 7946 mandates WGS84 for GeoJSON output, so a canonical EPSG:4326 payload interoperates with every standards-compliant consumer.

python
import logging
from functools import lru_cache
from typing import Optional, Dict, Any

from fastapi import FastAPI
from pydantic import BaseModel, Field
from pyproj import Transformer, CRS
from pyproj.exceptions import CRSError
from shapely.geometry import shape, mapping
from shapely.validation import make_valid
from shapely.ops import transform as shp_transform

app = FastAPI()
logger = logging.getLogger("crs_normalizer")

TARGET_CRS = "EPSG:4326"  # RFC 7946 canonical output for GeoJSON


class SpatialPayload(BaseModel):
    geometry: Dict[str, Any]              # GeoJSON-like geometry object
    crs: Optional[str] = None            # explicit CRS, e.g. "EPSG:3857"
    metadata: Dict[str, Any] = Field(default_factory=dict)


@lru_cache(maxsize=256)
def get_transformer(source_crs: str, target_crs: str) -> Transformer:
    # Compiling a PROJ pipeline is expensive; cache by the (source, target)
    # EPSG pair so repeated payloads from the same CRS reuse one object.
    # always_xy=True forces (x=longitude, y=latitude) order — PROJ 6+ otherwise
    # honours the authority axis order and silently swaps lon/lat for EPSG:4326.
    return Transformer.from_crs(
        CRS.from_user_input(source_crs),
        CRS.from_user_input(target_crs),
        always_xy=True,
    )


def resolve_source_crs(payload: SpatialPayload) -> str:
    # 1. Explicit field wins.
    if payload.crs:
        return payload.crs
    # 2. GeoJSON 2008-style crs member (deprecated by RFC 7946 but still emitted).
    member = payload.geometry.get("crs")
    if isinstance(member, dict):
        name = member.get("properties", {}).get("name")
        if name:
            return name
    # 3. Inference: geographic longitude/latitude never exceed |180|/|90|.
    #    A coordinate magnitude in the thousands means a projected grid, so a
    #    missing CRS that looks projected must NOT be assumed to be WGS84.
    x, y = _first_coordinate(payload.geometry)
    if abs(x) > 180 or abs(y) > 90:
        raise ValueError(
            f"coordinates ({x}, {y}) look projected but no CRS was supplied"
        )
    # 4. Per RFC 7946, GeoJSON with no CRS is WGS84.
    return TARGET_CRS


def _first_coordinate(geometry: Dict[str, Any]) -> tuple[float, float]:
    coords = geometry.get("coordinates")
    while isinstance(coords, list) and coords and isinstance(coords[0], list):
        coords = coords[0]
    if not isinstance(coords, list) or len(coords) < 2:
        raise ValueError("geometry has no usable coordinates")
    return float(coords[0]), float(coords[1])


def normalize(payload: SpatialPayload) -> Dict[str, Any]:
    source_crs = resolve_source_crs(payload)
    try:
        transformer = get_transformer(source_crs, TARGET_CRS)
    except CRSError as exc:
        raise ValueError(f"unresolvable CRS '{source_crs}': {exc}") from exc

    geom = shape(payload.geometry)
    geom = shp_transform(transformer.transform, geom)

    # A datum shift can introduce or expose self-intersections; re-check here,
    # not before the transform, so validity reflects the OUTPUT geometry.
    if not geom.is_valid:
        geom = make_valid(geom)
        if not geom.is_valid:
            raise ValueError("geometry still invalid after make_valid")

    return {
        "geometry": mapping(geom),
        "crs": TARGET_CRS,
        "source_crs": source_crs,        # keep provenance for auditing
        "metadata": payload.metadata,
    }


@app.post("/ingest")
async def ingest(payload: SpatialPayload):
    try:
        normalized = normalize(payload)
    except ValueError as exc:
        logger.warning("quarantining payload: %s", exc)
        # Push to a dead-letter / quarantine topic with the original input.
        return {"status": "quarantined", "reason": str(exc),
                "original": payload.model_dump()}
    # Publish `normalized` to your broker (Kafka, SQS, Redis Streams, ...).
    return {"status": "normalized", "payload": normalized}

Parameter reference

Argument / field Type Spatial constraint Default
payload.crs str or None Any pyproj-parseable CRS (e.g. EPSG:3857, +proj=utm +zone=31, WKT2) None (then inferred)
payload.geometry dict RFC 7946 geometry object; coordinates as [x, y] in the source CRS required
TARGET_CRS str Must be EPSG:4326 for RFC 7946 GeoJSON output; change only for internal sinks "EPSG:4326"
always_xy bool True forces (lon, lat) order; never omit for EPSG:4326 round-trips True
get_transformer cache lru_cache(maxsize=256) Sized to the count of distinct source EPSG codes you expect 256
Transformer.transform callable Operates on scalar/array coords; passed to shapely.ops.transform

Gotchas and spatial edge cases

  1. Axis-order swap on EPSG:4326. PROJ 6+ honours each authority’s declared axis order, which for EPSG:4326 is latitude-then-longitude. Omitting always_xy=True flips your coordinates and the error is silent — points land in the wrong hemisphere. Always pass always_xy=True when your data is (lon, lat).
  2. Inference must fail loud, not guess. A coordinate like (512345.0, 4781002.0) is clearly projected, not WGS84. Defaulting a missing CRS to EPSG:4326 would emit garbage; raise and quarantine instead.
  3. Validate topology after the transform. A datum shift moves vertices by metres-to-degrees, which can introduce self-intersections that did not exist in the source CRS. Running make_valid() before the transform validates the wrong geometry — check the output.
  4. Precision loss on round-trips. Reprojecting EPSG:3857EPSG:4326EPSG:3857 accumulates floating-point drift. Treat the normalized EPSG:4326 geometry as canonical and never reproject back for storage.
  5. Ring orientation is not enforced by transformation. pyproj moves coordinates but does not rewind polygon rings. RFC 7946 wants exterior rings counter-clockwise; if a downstream consumer is orientation-sensitive, run shapely.geometry.polygon.orient after normalizing. This matters when you later derive an idempotency key, as covered in Event Key Generation for Spatial Data — orientation changes the hash.
  6. CRS mismatch on merge. When combining features from multiple sources into one collection, normalize each to EPSG:4326 before the union. Merging raw geometries from different CRSes produces topologically meaningless results that no validation step can recover.

Minimal verification snippet

Run this with pytest to confirm a web-mercator point lands at the correct WGS84 longitude/latitude and that an unlabelled projected coordinate is rejected rather than mangled.

python
import math
from your_handler import normalize, SpatialPayload  # adjust import path


def test_web_mercator_point_normalizes_to_wgs84():
    # EPSG:3857 metres for roughly (lon=0, lat=0) — the origin.
    payload = SpatialPayload(
        geometry={"type": "Point", "coordinates": [0.0, 0.0]},
        crs="EPSG:3857",
    )
    out = normalize(payload)
    lon, lat = out["geometry"]["coordinates"]
    assert out["crs"] == "EPSG:4326"
    assert math.isclose(lon, 0.0, abs_tol=1e-6)
    assert math.isclose(lat, 0.0, abs_tol=1e-6)
    assert out["source_crs"] == "EPSG:3857"


def test_unlabelled_projected_coords_are_quarantined():
    payload = SpatialPayload(
        geometry={"type": "Point", "coordinates": [512345.0, 4781002.0]},
        crs=None,  # no CRS, magnitudes far exceed |180|/|90|
    )
    try:
        normalize(payload)
    except ValueError as exc:
        assert "projected" in str(exc)
    else:
        raise AssertionError("expected ValueError for unlabelled projected input")

FAQ

How do I infer a CRS when the payload omits one entirely?

Use coordinate magnitude as the first signal: geographic longitude never exceeds ±180 and latitude never exceeds ±90, so any value in the hundreds or thousands indicates a projected grid such as EPSG:3857 or a UTM zone. If the magnitude looks geographic, RFC 7946 lets you treat a missing CRS as EPSG:4326. If it looks projected, do not guess the specific zone — raise and quarantine, because picking the wrong UTM zone silently shifts every point by hundreds of kilometres.

Why cache the Transformer instead of building one per request?

Constructing a pyproj.Transformer compiles a PROJ pipeline and hits the PROJ database, which costs milliseconds per call — significant at webhook throughput. Caching by the (source_crs, target_crs) pair with lru_cache means each distinct source CRS pays that cost once. Modern pyproj transformers are thread-safe to call, so a cached object is safe to share across concurrent requests.

Should I transform to EPSG:4326 or keep the source CRS for storage?

Normalize to EPSG:4326 at ingestion so every downstream consumer — broker, spatial index, tile builder — receives one predictable system, and retain the original CRS string as provenance (the source_crs field above). Repeatedly reprojecting back and forth accumulates floating-point drift, so treat the EPSG:4326 output as canonical. If a sink genuinely needs the source projection, reproject from the canonical copy on demand rather than storing two divergent geometries.

Why re-validate geometry after the transform rather than before?

Reprojection moves every vertex, and a datum shift can introduce self-intersections or sliver artefacts that were not present in the source geometry. Validating before the transform certifies the wrong shape. Run is_valid and, if needed, make_valid() on the transformed output so the validity guarantee applies to exactly what you publish.