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-mercatorEPSG:3857or omit the CRS entirely. - Downstream consumers assume one CRS. Spatial indexes,
PostGIScolumns 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.
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.
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
- Axis-order swap on
EPSG:4326. PROJ 6+ honours each authority’s declared axis order, which forEPSG:4326is latitude-then-longitude. Omittingalways_xy=Trueflips your coordinates and the error is silent — points land in the wrong hemisphere. Always passalways_xy=Truewhen your data is(lon, lat). - Inference must fail loud, not guess. A coordinate like
(512345.0, 4781002.0)is clearly projected, not WGS84. Defaulting a missing CRS toEPSG:4326would emit garbage; raise and quarantine instead. - 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. - Precision loss on round-trips. Reprojecting
EPSG:3857→EPSG:4326→EPSG:3857accumulates floating-point drift. Treat the normalizedEPSG:4326geometry as canonical and never reproject back for storage. - Ring orientation is not enforced by transformation.
pyprojmoves coordinates but does not rewind polygon rings. RFC 7946 wants exterior rings counter-clockwise; if a downstream consumer is orientation-sensitive, runshapely.geometry.polygon.orientafter normalizing. This matters when you later derive an idempotency key, as covered in Event Key Generation for Spatial Data — orientation changes the hash. - CRS mismatch on merge. When combining features from multiple sources into one collection, normalize each to
EPSG:4326before 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.
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.
Related
- Parent topic: CRS Normalization Strategies for Geospatial Events
- Sibling how-to: Parsing GeoJSON Webhooks with FastAPI and Pydantic
- Domain overview: Spatial Payload Routing & Parsing