Handling Spatial Reference Mismatches in OGC Requests
TL;DR: Intercept incoming CRS/SRS parameters before they reach your spatial backend, validate them against the service’s advertised capabilities, swap axes where OGC 1.3.0 demands it, and transform bounding boxes to the native projection. Reject unsupported codes with a structured InvalidParameterValue exception — never silently fall back to a mismatched native CRS. The pyproj library handles all transformation and axis-order logic in three function calls.
The Core Challenge: Three Overlapping Sources of Drift
Spatial reference mismatches in OGC workflows are almost never caused by corrupt data. They arise from three spec-level ambiguities that compound each other in production:
Parameter naming divergence between WMS versions. WMS 1.1.1 uses the query parameter SRS, while WMS 1.3.0 replaced it with CRS. A client that omits version negotiation and mixes parameter names causes servers to ignore the projection declaration entirely, defaulting to a native CRS that silently misaligns the viewport. The SRS and Coordinate Reference System Handling guide covers the full terminology shift from SRS to CRS as OGC aligned with ISO 19111.
Axis-order inversion at the WMS 1.3.0 boundary. WMS 1.3.0 mandates that BBOX coordinates follow the axis order defined by the CRS authority. For EPSG:4326, that means lat,lon (y,x), not the lon,lat (x,y) order that WMS 1.1.1 and most web map clients assume. When a client sends a lon,lat bounding box to a 1.3.0 endpoint expecting lat,lon, the coordinates appear to cross the equator or the prime meridian, returning shifted geometries or an empty extent. Understanding OGC Web Map Service Specifications details this as one of the three breaking changes in the 1.1.1 to 1.3.0 transition.
Non-standard and deprecated EPSG identifiers. Clients frequently request EPSG:900913 (Google’s unofficial Web Mercator alias), hyperlocal national grid codes, or mistyped identifiers. A server with no transformation chain for these codes fails silently, distorts outputs, or returns a generic service exception with no diagnostic detail.
The diagram below shows how these three failure modes intersect at the request interception point, before data ever reaches the spatial backend:
Production-Ready Code
The following module validates a client-supplied CRS, enforces OGC axis order for WMS 1.3.0, transforms the bounding box to the service’s native CRS, and returns a structured fault on any failure. It depends only on pyproj and requests, both installable via pip.
import requests
from pyproj import CRS, Transformer
from pyproj.exceptions import ProjError
# Known legacy alias → canonical EPSG mappings.
# Normalise before any CRS lookup to avoid silent backend failures.
_CRS_ALIASES: dict[str, str] = {
"EPSG:900913": "EPSG:3857", # Unofficial Google Web Mercator alias
"EPSG:102100": "EPSG:3857", # ESRI alias for Web Mercator
"EPSG:102113": "EPSG:3857", # Older ESRI alias
}
def _normalise_crs(raw: str) -> str:
"""Map legacy/alias CRS identifiers to their canonical EPSG equivalents."""
return _CRS_ALIASES.get(raw.strip().upper(), raw.strip())
def fetch_capabilities_crs(endpoint: str) -> list[str]:
"""
Fetch supported CRS/SRS identifiers from a WMS GetCapabilities response.
Returns a list of canonical CRS strings (e.g. ['EPSG:4326', 'EPSG:3857']).
Requests WMS 1.3.0 by default; falls back to <SRS> elements for 1.1.1 servers.
"""
params = {"SERVICE": "WMS", "VERSION": "1.3.0", "REQUEST": "GetCapabilities"}
resp = requests.get(endpoint, params=params, timeout=10)
resp.raise_for_status()
# Use lxml for reliable namespace-aware XPath
from lxml import etree
ns = {"wms": "http://www.opengis.net/wms"}
root = etree.fromstring(resp.content)
# WMS 1.3.0 uses <CRS>; 1.1.1 uses <SRS> — try both
nodes = root.findall(".//wms:CRS", ns) or root.findall(".//wms:SRS", ns)
return [node.text.strip() for node in nodes if node.text]
def validate_and_transform_bbox(
client_crs: str,
bbox: list[float],
native_crs: str = "EPSG:4326",
wms_version: str = "1.3.0",
supported_crs: list[str] | None = None,
) -> list[float]:
"""
Validate a client-supplied CRS, normalise axis order for OGC compliance,
and transform the bounding box to the service's native CRS.
Parameters
----------
client_crs : CRS string exactly as sent by the client (e.g. 'EPSG:4326').
bbox : Four-element list from the client BBOX parameter.
For WMS 1.3.0 + EPSG:4326 this arrives as [minLat, minLon, maxLat, maxLon].
native_crs : The CRS your backend stores and queries in.
wms_version : '1.3.0' enforces OGC axis order; '1.1.1' assumes lon,lat always.
supported_crs : Optional allowlist from GetCapabilities. Raises ValueError if the
normalised CRS is absent. Skip allowlist check when None.
Returns
-------
list[float] : [minX, minY, maxX, maxY] in native_crs, always in x,y order.
Raises
------
ValueError : With an OGC-compliant fault message on any validation failure.
"""
canonical = _normalise_crs(client_crs)
# Allowlist check: reject anything the service does not advertise
if supported_crs is not None:
normalised_supported = [_normalise_crs(c) for c in supported_crs]
if canonical not in normalised_supported:
raise ValueError(
f"InvalidParameterValue: CRS '{client_crs}' is not in the "
f"service's advertised capabilities."
)
# Attempt to build pyproj CRS objects; catches typos and unknown identifiers
try:
src_crs = CRS.from_user_input(canonical)
dst_crs = CRS.from_user_input(native_crs)
except ProjError as exc:
raise ValueError(
f"InvalidParameterValue: Unrecognised CRS '{canonical}'. {exc}"
) from exc
# WMS 1.3.0 delivers BBOX in the CRS authority's declared axis order.
# EPSG:4326 has latitude (north) as axis 0, so the client sends [lat, lon, lat, lon].
# We need [lon, lat, lon, lat] (x,y) for Transformer.from_crs with always_xy=True.
bbox_xy = list(bbox)
if wms_version == "1.3.0":
axis_info = src_crs.axis_info
if len(axis_info) >= 2 and axis_info[0].direction in ("north", "south"):
# Swap: [lat_min, lon_min, lat_max, lon_max] → [lon_min, lat_min, lon_max, lat_max]
bbox_xy = [bbox[1], bbox[0], bbox[3], bbox[2]]
# always_xy=True keeps the transformer in consistent x,y space regardless of CRS definitions
transformer = Transformer.from_crs(src_crs, dst_crs, always_xy=True)
try:
min_x, min_y = transformer.transform(bbox_xy[0], bbox_xy[1])
max_x, max_y = transformer.transform(bbox_xy[2], bbox_xy[3])
except ProjError as exc:
raise ValueError(
f"InvalidParameterValue: Coordinate transformation failed "
f"from '{canonical}' to '{native_crs}'. {exc}"
) from exc
return [min_x, min_y, max_x, max_y]
Step-by-Step Walkthrough
Alias normalisation (_normalise_crs). The first thing the pipeline does is remap legacy identifiers to their canonical equivalents. EPSG:900913 and EPSG:3857 describe the same Web Mercator projection — but many servers only advertise EPSG:3857 in their GetCapabilities document. By normalising before allowlist lookup, the gateway accepts what the client sends while routing correctly.
Capabilities extraction (fetch_capabilities_crs). The function requests the GetCapabilities document at WMS 1.3.0 and extracts all <CRS> elements using lxml’s namespace-aware XPath. It falls back to <SRS> for 1.1.1 servers whose documents use the older element name. The returned list becomes your validation allowlist — cache it per endpoint to avoid per-request HTTP overhead.
Allowlist check. If supported_crs is provided, the gateway rejects any CRS not in the normalised allowlist before attempting transformation. This prevents downstream errors in PostGIS or GeoServer when an unsupported projection reaches the data layer.
CRS object construction and error handling. CRS.from_user_input() accepts EPSG codes, PROJ strings, and WKT. When it raises ProjError, the exception wraps the PROJ library’s diagnostic message, which is then surfaced in an OGC-formatted InvalidParameterValue fault — giving clients enough context to fix the request rather than file a support ticket.
Axis-order correction. The axis_info property on a pyproj.CRS object returns the CRS authority’s declared axis directions. For EPSG:4326, axis_info[0].direction is "north" (latitude first). When the WMS version is 1.3.0, the gateway detects this and swaps the BBOX from the OGC wire order (lat,lon) back to lon,lat (x,y) before passing it to the Transformer. The always_xy=True parameter on Transformer.from_crs then keeps everything in consistent x,y space regardless of what either CRS declares.
Transformation. transformer.transform() projects each corner coordinate pair. The function returns [minX, minY, maxX, maxY] in the native CRS, in x,y order, ready for a PostGIS ST_MakeEnvelope call or a GeoServer GetMap proxy request.
Verification
Given a 1.3.0 client requesting the UK in EPSG:4326 with correct OGC axis order (lat,lon):
supported = fetch_capabilities_crs("https://your-wms-endpoint/ows")
result = validate_and_transform_bbox(
client_crs="EPSG:4326",
bbox=[49.8, -8.2, 60.9, 2.1], # [minLat, minLon, maxLat, maxLon] — 1.3.0 order
native_crs="EPSG:3857",
wms_version="1.3.0",
supported_crs=supported,
)
print(result)
Expected output (Web Mercator metres):
[-912889.3, 6401005.3, 233768.2, 8618523.4]
To confirm axis order is handled correctly, try the same call with wms_version="1.1.1" — the output coordinates should shift significantly, proving the swap path is active only for 1.3.0.
Gotchas and Edge Cases
CRS:84 is not the same token as EPSG:4326 in allowlist checks. Many clients send CRS:84 when targeting 1.3.0 endpoints to sidestep the axis-order inversion (CRS:84 explicitly specifies lon,lat order). If your allowlist comes from a server that only advertises EPSG:4326, the allowlist check fails even though the projections are geographically identical. Extend _CRS_ALIASES to map "CRS:84" → "EPSG:4326" and skip the axis swap for CRS:84 by checking canonical != "EPSG:4326" before examining axis_info.
Polar projections near the antimeridian. Transformer.transform() may return inf or very large coordinate values for bounding boxes that cross 180° longitude or approach the poles with projections like EPSG:3995 (Arctic Polar Stereographic). Add a bounds check after transformation — if any coordinate exceeds the expected domain for the native CRS, return a fault rather than forwarding a corrupt envelope to the backend.
WFS srsName attribute uses a different URI syntax. WFS transactional operations pass the CRS as an srsName URN (urn:ogc:def:crs:EPSG::4326) rather than a plain EPSG:4326 token in query parameters. Run srsName values through CRS.from_user_input() directly — pyproj accepts both forms — but do not feed URN strings into _CRS_ALIASES, which expects the colon-separated token format. The WFS Transactional Operations Deep Dive covers srsName placement in GML Insert payloads in detail.
Caching GetCapabilities per endpoint, not globally. A single backend may expose multiple OGC endpoints with different advertised CRS sets (e.g. a raster coverage service supports fewer projections than the vector WFS). Cache the CRS allowlist keyed by the full endpoint URL, and set a TTL of at least 10 minutes — capabilities documents change only on server configuration updates, not per request.
Back to SRS and Coordinate Reference System Handling
Related
- SRS and Coordinate Reference System Handling — on-the-fly reprojection, axis-order conventions, and pyproj integration across OGC services
- Understanding OGC Web Map Service Specifications — WMS 1.1.1 vs 1.3.0 parameter changes including the CRS/SRS rename and BBOX axis rules
- WFS Transactional Operations Deep Dive — srsName handling in GML Insert, Update, and Delete operations