Handling Mixed SRID Inputs from Legacy Clients

Accept geometry from clients that send eastings, reversed axis order or no CRS at all — and normalise it to one storage SRID before it reaches the table.

← 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;
Four client dialects, one storage systemFour inbound shapes on the left: a GeoJSON client sending longitude and latitude, a WFS client sending latitude first, a survey tool sending British National Grid eastings and northings, and a spreadsheet import with no declared system. Each passes through a validation gate that checks the coordinate magnitudes against the declared code. Valid inputs are relabelled with ST_SetSRID and moved with ST_Transform into EPSG 4326 storage. The undeclared input is rejected with a 422 rather than assumed.Normalising on the write pathGeoJSON client[-0.1276, 51.5072] crs=4326WFS client[51.5072, -0.1276] crs=4326Survey tool[530034, 180381] crs=27700Spreadsheet import[530034, 180381] crs=?Magnitude gatedo the numbers fitthe declared code?ST_SetSRID → ST_Transformlabel, then move — never one alonestored as geometry(…, 4326)422 unsupported / ambiguousnames the field and the likely causeno row is writtenThe WFS client is caught by the axis-order branch: latitude first is detectable, so it gets a specific message.

Key Parameters & Options

ControlSettingWhy
crs parameterrequired on write, defaulted on readA default on write is what allows silent corruption
SRID_BOUNDSper supported EPSG codeCheap magnitude check that catches the three common dialects
Axis-order branch4326 onlyLatitude above 90 is impossible, so the reading is unambiguous
ST_SetSRIDalways paired with ST_TransformAlone it relabels without moving — the classic corruption
Column typegeometry(<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.

How detectable each dialect mistake is from the numbers aloneFive failure modes rated. A projected coordinate labelled as degrees is caught essentially always, because six-digit values cannot be degrees. Reversed axis order is caught about 95 percent of the time, failing only when both values are under 90. A wrong national grid, such as Lambert-93 labelled as British National Grid, is caught about 70 percent of the time by bounds. Web Mercator labelled as 4326 is caught almost always. A point in the wrong hemisphere with a valid sign is never detectable and needs a business rule instead.Detectability of each mistake from coordinate values alone0 %50 %100 %Eastings labelled as degrees~100 %Web Mercator labelled 432699 %Reversed axis order95 %Wrong national grid70 %Valid values, wrong place0 %The last row is why a coverage constraint — "features must fall inside the tenant's operating area" — belongs in the schema too.

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 geometries at query time means normalisation was skipped somewhere — usually a bulk path that writes with COPY and bypasses the API. Audit with SELECT 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 crs on 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_GeomFromGeoJSON on a crs member. 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.

Two rejections for the same requestTwo panels. On the left, a vague response reading invalid geometry with a 400 status, annotated as requiring the integrator to guess among coordinate order, wrong system and malformed payload, typically hours of work. On the right, a specific 422 naming the axis_order cause, echoing the received coordinates and stating the required order, annotated as a one-line client fix.Same bad request, two error contracts✕ vague400 Bad Request{"error": "invalid geometry"}Client must guess: order? system? payload?typical time to fix: hours✓ specific422 Unprocessableaxis_order: got (51.5072, -0.1276);GeoJSON requires (lon, lat)Cause named, value echoed, contract statedtypical time to fix: one lineEchoing the rejected value matters: it proves to the integrator what the server actually parsed, which isoften different from what they believe they sent.

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 | 412903

← Back to Coordinate Reference Systems & SRID Handling