← Back to Coordinate Reference Systems & SRID Handling
This page shows how to accept writes from clients that disagree about coordinate systems — eastings from a survey tool, reversed axis order from a WFS client, unlabelled pairs from a spreadsheet import — and land them all in one storage SRID with no silent corruption.
Context & When to Use
An API that only ever talks to its own front end can mandate GeoJSON in EPSG:4326 and be done. The moment it is exposed to third parties, that assumption fails in three specific ways. Survey and planning tools emit a national grid, because that is what their instruments and legal records use. Standards-compliant WFS clients emit latitude first, because that is what the EPSG registry defines for 4326, even though GeoJSON mandates the opposite. And bulk imports emit whatever was in the file, frequently with no system recorded anywhere.
None of these produce an error on ingest. A British National Grid easting of 530034 stored as a longitude is a point in the Pacific; a reversed pair is a point in the Indian Ocean. Both draw fine on a map, in the wrong place, and are only noticed weeks later by someone who knows the area. That is why the defences belong on the write path, before the row exists — the same reasoning behind the Pydantic geometry validators that reject malformed rings.
Use this pattern on every public write endpoint, and on bulk ingestion in particular, where a single mislabelled file can contaminate a million rows before anyone looks at a map.
Runnable Implementation
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field, model_validator
STORAGE_SRID = 4326
# Plausible coordinate magnitudes per system — used to REJECT, never to guess
SRID_BOUNDS: dict[int, tuple[float, float, float, float]] = {
4326: (-180.0, -90.0, 180.0, 90.0),
3857: (-20_037_509.0, -20_048_967.0, 20_037_509.0, 20_048_967.0),
27700: (0.0, 0.0, 700_000.0, 1_300_000.0), # British National Grid
2154: (-378_000.0, 6_000_000.0, 1_212_000.0, 7_230_000.0), # Lambert-93
}
class GeometryIn(BaseModel):
"""A posted geometry plus the system its coordinates are expressed in."""
type: Literal["Point", "LineString", "Polygon", "MultiPolygon"]
coordinates: list[Any]
crs: Annotated[int, Field(description="EPSG code of `coordinates`")] = STORAGE_SRID
@model_validator(mode="after")
def coordinates_must_suit_the_declared_crs(self) -> "GeometryIn":
bounds = SRID_BOUNDS.get(self.crs)
if bounds is None:
raise ValueError(f"unsupported_crs: {self.crs}")
minx, miny, maxx, maxy = bounds
for x, y in _iter_positions(self.coordinates):
if not (minx <= x <= maxx and miny <= y <= maxy):
# Reversed axis order is the most likely cause for 4326 — say so
if self.crs == 4326 and abs(x) <= 90 < abs(y) <= 180:
raise ValueError(
"axis_order: coordinates look like (lat, lon); "
"GeoJSON requires (lon, lat)"
)
raise ValueError(
f"coordinate_out_of_range_for_crs: ({x}, {y}) cannot be EPSG:{self.crs}"
)
return self
def _iter_positions(coords: Any):
"""Yield every (x, y) pair from an arbitrarily nested coordinate array."""
if coords and isinstance(coords[0], (int, float)):
yield float(coords[0]), float(coords[1])
return
for part in coords:
yield from _iter_positions(part)The insert then labels and moves the coordinates in one statement — never one without the other:
INSERT INTO features (layer, geom)
VALUES (
$1,
-- $2 = GeoJSON text, $3 = the DECLARED EPSG code
ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON($2), $3), 4326)
)
RETURNING id, ST_SRID(geom) AS stored_srid;Key Parameters & Options
| Control | Setting | Why |
|---|---|---|
crs parameter | required on write, defaulted on read | A default on write is what allows silent corruption |
SRID_BOUNDS | per supported EPSG code | Cheap magnitude check that catches the three common dialects |
| Axis-order branch | 4326 only | Latitude above 90 is impossible, so the reading is unambiguous |
ST_SetSRID | always paired with ST_Transform | Alone it relabels without moving — the classic corruption |
| Column type | geometry(<type>, 4326) | Database-level backstop for anything the API misses |
Detection reliability by dialect
Not every wrong input is detectable. Knowing which ones slip through decides how much you invest in the rest of the pipeline.
That final case is the argument for a coverage CHECK constraint or an row-level security policy that bounds where a tenant may write: no value inspection catches a plausible point in the wrong country, but a business rule does.
Gotchas & Failure Modes
ERROR: Operation on mixed SRID geometriesat query time means normalisation was skipped somewhere — usually a bulk path that writes withCOPYand bypasses the API. Audit withSELECT DISTINCT ST_SRID(geom) FROM features.- Swapping axes automatically. Tempting and wrong: a point at 51.5, 0.13 is valid either way, so a silent swap corrupts exactly the data it cannot verify. Reject with the axis-order message and let the client fix its request.
- Defaulting
crson write. A default turns “the client forgot” into “the server guessed”. Make it required for writes even though it is defaulted for reads. - Bounds that are too tight. Lambert-93 legitimately extends past mainland France to overseas grids; a bounds check calibrated only to Paris rejects valid data. Take the bounds from the EPSG area of use, not from the sample data.
ST_GeomFromGeoJSONon acrsmember. RFC 7946 removed it, and PostGIS ignores it. A client that helpfully embeds"crs": {...}in the geometry object will be silently ignored — read the system from your own parameter, never from the payload.
What a rejection should tell the client
A 422 that says “invalid geometry” costs the integrator an afternoon. A 422 that names the field, echoes the value it rejected and states the likely cause is usually fixed on the next request. The error bodies in the verification section below follow that shape deliberately: each one is specific enough that the client can tell which of the three dialect problems it has without reading the API documentation.
Verification Snippet
# Correct: declared national grid, transformed on write
curl -s -X POST localhost:8000/v1/features -H 'content-type: application/json' \
-d '{"layer":"parcels","geometry":{"type":"Point","coordinates":[530034,180381],"crs":27700}}'
# {"id":8412,"stored_srid":4326}
# Reversed axis order: caught, with a specific message
curl -s -X POST localhost:8000/v1/features -H 'content-type: application/json' \
-d '{"layer":"parcels","geometry":{"type":"Point","coordinates":[51.5072,-0.1276],"crs":4326}}'
# 422 {"detail":[{"msg":"Value error, axis_order: coordinates look like (lat, lon); GeoJSON requires (lon, lat)"}]}
# Eastings mislabelled as degrees: caught by magnitude
curl -s -X POST localhost:8000/v1/features -H 'content-type: application/json' \
-d '{"layer":"parcels","geometry":{"type":"Point","coordinates":[530034,180381],"crs":4326}}'
# 422 {"detail":[{"msg":"Value error, coordinate_out_of_range_for_crs: (530034.0, 180381.0) cannot be EPSG:4326"}]}-- The table should only ever hold one system
SELECT ST_SRID(geom) AS srid, count(*) FROM features GROUP BY 1;
-- srid | count
-- ------+--------
-- 4326 | 412903Related
- Coordinate Reference Systems & SRID Handling — the storage decision these validators protect
- Validating WKT and GeoJSON with Pydantic v2 — the shape checks that run alongside these range checks
- Handling Async File Uploads for Shapefile Processing — where mislabelled bulk data enters