SRS and Coordinate Reference System Handling

Coordinate reference system mismatches are the single most common cause of misaligned layers, empty tile responses, and geometry rejections in OGC service stacks. The terminology shift from Spatial Reference System (SRS) to Coordinate Reference System (CRS) reflects OGC’s alignment with ISO 19111, but both terms coexist across specification versions, and confusing them — or the axis-order rules they carry — triggers immediate service failures. This guide provides a production-ready CRS pipeline, tested pyproj patterns, and error-resolution strategies for GIS platform engineers running WMS, WFS, and WMTS endpoints.

Prerequisites & Architecture Context

Before wiring up a CRS pipeline, confirm your environment meets these baseline requirements:

  • Python 3.10+ with pyproj>=3.4.0, requests>=2.28.0, and lxml>=4.9.0
  • A local EPSG registry cache or access to the authoritative EPSG Geodetic Parameter Dataset
  • proj-data packages installed so datum-shift grids (NTv2, NADCON5) are available to PROJ
  • A spatially enabled backend: PostGIS, GeoServer, or a custom vector/raster store

CRS handling sits at the intersection of every OGC operation. The broader architectural context — how CRS declarations flow from GetCapabilities responses into individual operation requests — is covered in OGC Standards Architecture & Service Fundamentals. Within that framework, this page addresses the specific mechanics of identifier resolution, axis normalisation, and transformation pipeline construction.

How CRS Flows Through an OGC Request

The diagram below shows the three decision points where axis order must be consciously handled: at the client boundary when reading a capabilities document, inside the transformation pipeline, and at the serialisation boundary when constructing the outgoing OGC request.

CRS Flow Through an OGC Request Pipeline Diagram showing three stages: parsing CRS from GetCapabilities, normalising to x,y internally via pyproj, and serialising with correct axis order for WMS 1.1.1 or WMS 1.3.0. INGEST GetCapabilities XML response Parse SRS / CRS authority codes Validate + reject unknown codes resolve TRANSFORM Internal Pipeline always_xy=True pyproj Transformer normalised x,y Datum-shift grids NTv2 / NADCON5 serialise SERIALISE OGC Request WMS / WFS / WMTS SRS= (v1.1.1) CRS= (v1.3.0+) Apply axis swap at boundary only Axis swap happens only at serialisation — never inside the transform stage

Specification Deep-Dive: SRS vs. CRS Across OGC Versions

Parameter Naming and Axis-Order Rules

The table below captures the breaking changes between specification versions. The axis-order column is the most operationally significant: getting it wrong produces a spatially valid response in the wrong location, which is harder to detect than an outright error.

Service & Version Parameter Name BBOX Axis Order for EPSG:4326 Notes
WMS 1.1.1 SRS minLon,minLat,maxLon,maxLat lon,lat regardless of EPSG definition; use SRS=EPSG:4326
WMS 1.3.0 CRS minLat,minLon,maxLat,maxLon Authority-defined order; CRS:84 alias restores lon,lat
WFS 1.1.0 srsName in GML lon,lat in gml:pos Inconsistently implemented; verify per server
WFS 2.0 srsName in GML Axis order per EPSG Strictly authority-defined; declare explicitly
WMTS 1.0 Tile Matrix Set Implicit per TMS definition No explicit CRS parameter in tile requests

Understanding OGC Web Map Service Specifications provides the full GetMap parameter reference for both WMS versions. For WFS GML serialisation rules and the srsName attribute, see WFS Transactional Operations Deep Dive.

Axis-Order Mechanics

EPSG:4326 defines latitude as the first (northing) axis. WMS 1.3.0 respects that definition, so BBOX=51.4,-0.2,51.6,0.1 is a valid London bounding box — not BBOX=-0.2,51.4,0.1,51.6. The alias CRS:84 was introduced precisely to provide an explicit lon,lat declaration when targeting 1.3.0 endpoints.

Projected CRS definitions (e.g., EPSG:27700 British National Grid, EPSG:3857 Web Mercator) follow easting,northing — x,y — so those require no axis swap in WMS 1.3.0 requests. The axis problem is specific to geographic CRS.

GML srsName Fragments

WFS responses embed CRS declarations inside GML geometry elements. Correct axis handling requires matching the srsName to the active service version:

<!-- WFS 2.0 GML 3.2 — axis order per EPSG:4326 (lat,lon) -->
<gml:Point srsName="urn:ogc:def:crs:EPSG::4326" srsDimension="2">
  <gml:pos>51.5074 -0.1278</gml:pos>
</gml:Point>

<!-- WFS 2.0 GML 3.2 — lon,lat via CRS:84 alias -->
<gml:Point srsName="urn:ogc:def:crs:OGC:1.3:CRS84" srsDimension="2">
  <gml:pos>-0.1278 51.5074</gml:pos>
</gml:Point>

Using the urn:ogc:def:crs:EPSG::4326 form with lon,lat coordinate order is one of the most common WFS-T insert failures. Validate srsName URN format against the target server’s GetCapabilities before submitting transactions.

Python Implementation

The following module implements all five stages of the CRS pipeline: identifier validation, axis-order resolution, transformation, request parameter construction, and output validation. It is designed to be imported into a service layer or used standalone.

"""
crs_pipeline.py — Production CRS handling for OGC service clients.
Requires: pyproj>=3.4.0
"""

from __future__ import annotations

import logging
from functools import lru_cache
from typing import Tuple

import pyproj
from pyproj import CRS, Transformer
from pyproj.exceptions import ProjError

log = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Stage 1: Identifier validation and CRS resolution
# ---------------------------------------------------------------------------

@lru_cache(maxsize=256)
def resolve_crs(auth_code: str) -> CRS:
    """
    Validate and cache a CRS identifier.

    Accepts EPSG codes ('EPSG:4326'), URNs ('urn:ogc:def:crs:EPSG::4326'),
    and OGC aliases ('CRS:84'). Raises ValueError for unknown codes.
    lru_cache avoids repeated PROJ database lookups across request handlers.
    """
    try:
        return CRS.from_user_input(auth_code.strip())
    except Exception as exc:
        raise ValueError(f"Unrecognised or unsupported CRS: {auth_code!r}") from exc


def is_geographic(auth_code: str) -> bool:
    """Return True if the CRS is a geographic (lat/lon) system."""
    return resolve_crs(auth_code).is_geographic


# ---------------------------------------------------------------------------
# Stage 2: Axis-order resolution
# ---------------------------------------------------------------------------

def wms_axis_order(auth_code: str, wms_version: str) -> str:
    """
    Return the BBOX axis ordering string expected by a WMS endpoint.

    WMS 1.1.1 always uses lon,lat for geographic CRS.
    WMS 1.3.0 follows the CRS authority definition.
    Projected CRS always use x,y (easting,northing) in both versions.
    """
    crs = resolve_crs(auth_code)
    if wms_version.startswith("1.3"):
        axes = [a.direction for a in crs.axis_info]
        if axes and axes[0] in ("north", "south"):
            return "lat,lon"
        return "lon,lat"
    # WMS 1.1.1: geographic CRS always lon,lat; projected follows x,y
    return "lat,lon" if False else "lon,lat"  # 1.1.1 always lon,lat


# ---------------------------------------------------------------------------
# Stage 3: Transformation
# ---------------------------------------------------------------------------

@lru_cache(maxsize=128)
def _get_transformer(source: str, target: str) -> Transformer:
    """
    Build and cache a pyproj Transformer.

    always_xy=True enforces lon,lat (x,y) internally regardless of EPSG
    axis definitions. Apply the WMS axis swap only at serialisation.
    Thread-safe in pyproj >= 3.0.
    """
    return Transformer.from_crs(
        resolve_crs(source),
        resolve_crs(target),
        always_xy=True,
    )


def transform_bbox(
    bbox: Tuple[float, float, float, float],
    source_crs: str,
    target_crs: str,
) -> Tuple[float, float, float, float]:
    """
    Reproject a bounding box from source_crs to target_crs.

    bbox must be in (minx, miny, maxx, maxy) / (minlon, minlat, maxlon, maxlat)
    order — i.e. always_xy convention. Raises RuntimeError on grid failure.
    """
    if source_crs == target_crs:
        return bbox

    t = _get_transformer(source_crs, target_crs)
    try:
        minx, miny = t.transform(bbox[0], bbox[1])
        maxx, maxy = t.transform(bbox[2], bbox[3])
        return (minx, miny, maxx, maxy)
    except ProjError as exc:
        raise RuntimeError(
            f"Transformation failed ({source_crs} -> {target_crs}): {exc}"
        ) from exc


# ---------------------------------------------------------------------------
# Stage 4: OGC request parameter construction
# ---------------------------------------------------------------------------

def build_wms_bbox(
    bbox_xy: Tuple[float, float, float, float],
    crs_code: str,
    wms_version: str,
) -> str:
    """
    Serialise a bounding box to the correct WMS BBOX string.

    bbox_xy is the internal always_xy (minlon,minlat,maxlon,maxlat) form.
    The axis swap for WMS 1.3.0 geographic CRS is applied here only.
    """
    minx, miny, maxx, maxy = bbox_xy
    order = wms_axis_order(crs_code, wms_version)

    if order == "lat,lon":
        # WMS 1.3.0 + geographic CRS: swap to minlat,minlon,maxlat,maxlon
        return f"{miny},{minx},{maxy},{maxx}"
    return f"{minx},{miny},{maxx},{maxy}"


def wms_crs_param(wms_version: str) -> str:
    """Return 'SRS' for WMS 1.1.x or 'CRS' for WMS 1.3.x."""
    return "SRS" if wms_version.startswith("1.1") else "CRS"


# ---------------------------------------------------------------------------
# Stage 5: Output validation
# ---------------------------------------------------------------------------

def bbox_intersects(
    a: Tuple[float, float, float, float],
    b: Tuple[float, float, float, float],
) -> bool:
    """Lightweight BBOX intersection check in always_xy convention."""
    return not (a[2] < b[0] or b[2] < a[0] or a[3] < b[1] or b[3] < a[1])


# ---------------------------------------------------------------------------
# Usage examples
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    # San Francisco in EPSG:4326 (always_xy: lon,lat)
    sf_bbox_4326 = (-122.52, 37.70, -122.35, 37.83)

    # Reproject to Web Mercator for a WMS 1.1.1 GetMap request
    sf_bbox_3857 = transform_bbox(sf_bbox_4326, "EPSG:4326", "EPSG:3857")
    print("EPSG:3857 BBOX:", sf_bbox_3857)

    # WMS 1.1.1 — SRS parameter, lon,lat bbox ordering
    bbox_111 = build_wms_bbox(sf_bbox_4326, "EPSG:4326", "1.1.1")
    print(f"WMS 1.1.1  SRS=EPSG:4326  BBOX={bbox_111}")
    # -> BBOX=-122.52,37.70,-122.35,37.83

    # WMS 1.3.0 — CRS parameter, lat,lon bbox ordering for EPSG:4326
    bbox_130 = build_wms_bbox(sf_bbox_4326, "EPSG:4326", "1.3.0")
    print(f"WMS 1.3.0  CRS=EPSG:4326  BBOX={bbox_130}")
    # -> BBOX=37.70,-122.52,37.83,-122.35

    # WMS 1.3.0 — CRS:84 alias keeps lon,lat ordering
    bbox_crs84 = build_wms_bbox(sf_bbox_4326, "CRS:84", "1.3.0")
    print(f"WMS 1.3.0  CRS=CRS:84     BBOX={bbox_crs84}")
    # -> BBOX=-122.52,37.70,-122.35,37.83

Code Walkthrough

resolve_crs uses lru_cache to avoid hitting the PROJ database on every request. It accepts EPSG codes, urn:ogc:def:crs:EPSG:: URNs, and OGC aliases such as CRS:84 interchangeably.

wms_axis_order inspects the CRS axis info at runtime rather than maintaining a hard-coded list. crs.axis_info[0].direction == "north" reliably detects geographic CRS in their authority-native (lat-first) orientation.

_get_transformer is the performance-critical path. Transformer construction reads PROJ grid files; caching it per (source, target) pair means a busy service builds each pair once and reuses it across thousands of requests.

build_wms_bbox is the only place where the axis swap occurs. Every upstream step works in the always_xy convention; the swap is applied at the final serialisation boundary.

Error Handling & Edge Cases

ServiceException Types

OGC services return XML ServiceExceptionReport payloads rather than HTTP error bodies. Parse the code attribute to distinguish CRS errors from other failures:

import xml.etree.ElementTree as ET

def parse_service_exception(xml_text: str) -> dict:
    """Extract code and message from a WMS/WFS ServiceExceptionReport."""
    root = ET.fromstring(xml_text)
    # WMS 1.1.1 uses no namespace; 1.3.0 uses the OGC namespace
    ns = {"ogc": "http://www.opengis.net/ogc"}
    exc = root.find(".//ServiceException") or root.find(".//ogc:ServiceException", ns)
    if exc is None:
        return {"code": "Unknown", "message": xml_text[:200]}
    return {
        "code": exc.get("code", "Unknown"),
        "message": (exc.text or "").strip(),
    }

Common CRS-related codes and their causes:

code value Likely Cause
InvalidParameterValue Unsupported CRS code, or wrong parameter name (SRS vs CRS)
InvalidSRS CRS not declared in GetCapabilities Layer SRS/CRS list
InvalidBBOX Incorrect axis order or values outside valid CRS extent
LayerNotDefined Correct CRS but wrong layer name — rule out before suspecting CRS

Axis-Order Traps

The EPSG:4326 lat,lon trap is the most common. If rendered features appear rotated 90 degrees or are located in the ocean instead of their true position, the BBOX axis order is inverted. Fix by either using CRS:84 or explicitly swapping the BBOX in your build_wms_bbox call.

The EPSG:900913 legacy code predates EPSG:3857 and is not present in all PROJ databases. Map it to EPSG:3857 before passing to resolve_crs.

Concatenated transforms across three or more CRS can accumulate accuracy loss. Whenever possible, transform directly from source to final target rather than chaining through intermediate CRS. pyproj selects the most accurate single-step transform automatically.

For diagnosis patterns specific to geometry rejection in federated data pipelines, Handling Spatial Reference Mismatches in OGC Requests covers systematic diagnostic workflows.

Empty Result Sets

An empty GetMap image or zero-feature GetFeature response that produces no error code is almost always a BBOX issue. The request was valid, but the bounding box specified a region the layer does not cover — typically because axis inversion placed the box in a different hemisphere. Debug by:

  1. Exporting the transformed BBOX to GeoJSON and loading it in QGIS or another client.
  2. Comparing the BBOX extent to the layer’s native extent from GetCapabilities.
  3. Checking the EX_GeographicBoundingBox element (WMS 1.3.0), which is always in lon,lat and independent of axis-order rules.

Testing & Compliance Verification

Unit Test Skeleton

import pytest
from crs_pipeline import resolve_crs, transform_bbox, build_wms_bbox, bbox_intersects

class TestAxisOrder:
    def test_wms111_geographic_is_lonlat(self):
        bbox = build_wms_bbox((-10.0, 50.0, 2.0, 59.0), "EPSG:4326", "1.1.1")
        assert bbox == "-10.0,50.0,2.0,59.0"

    def test_wms130_geographic_is_latlot(self):
        bbox = build_wms_bbox((-10.0, 50.0, 2.0, 59.0), "EPSG:4326", "1.3.0")
        assert bbox == "50.0,-10.0,59.0,2.0"

    def test_wms130_crs84_is_lonlat(self):
        bbox = build_wms_bbox((-10.0, 50.0, 2.0, 59.0), "CRS:84", "1.3.0")
        assert bbox == "-10.0,50.0,2.0,59.0"

class TestTransformation:
    def test_identity_transform_returns_input(self):
        bbox = (-122.5, 37.7, -122.3, 37.9)
        assert transform_bbox(bbox, "EPSG:4326", "EPSG:4326") == bbox

    def test_4326_to_3857_roundtrip(self):
        original = (-122.5, 37.7, -122.3, 37.9)
        projected = transform_bbox(original, "EPSG:4326", "EPSG:3857")
        restored = transform_bbox(projected, "EPSG:3857", "EPSG:4326")
        assert abs(restored[0] - original[0]) < 1e-6

    def test_invalid_crs_raises_valueerror(self):
        with pytest.raises(ValueError, match="Unrecognised"):
            resolve_crs("EPSG:9999999")

class TestBboxIntersection:
    def test_overlapping_bboxes(self):
        a = (0.0, 0.0, 10.0, 10.0)
        b = (5.0, 5.0, 15.0, 15.0)
        assert bbox_intersects(a, b) is True

    def test_non_overlapping_bboxes(self):
        a = (0.0, 0.0, 5.0, 5.0)
        b = (6.0, 6.0, 10.0, 10.0)
        assert bbox_intersects(a, b) is False

OGC CITE Compliance

The OGC Compliance & Interoperability Testing & Evaluation (CITE) programme provides executable conformance tests for WMS and WFS. The test suite verifies correct CRS declaration in GetCapabilities, valid BBOX handling for each advertised CRS, and correct axis-order behaviour for 1.3.0 endpoints. Run the CITE WMS 1.3.0 test engine against your service to catch axis-order and CRS advertisement gaps before they reach production.

Performance & Scaling Notes

Transformer Caching

The _get_transformer lru_cache is effective for services with a stable set of CRS pairs (e.g., always reprojecting from native storage CRS to EPSG:4326 and EPSG:3857). For services with hundreds of distinct source CRS combinations, use a bounded cachetools.TTLCache or pre-warm common pairs at startup:

COMMON_PAIRS = [
    ("EPSG:4326", "EPSG:3857"),
    ("EPSG:27700", "EPSG:4326"),
    ("EPSG:2154", "EPSG:4326"),   # French RGF93/Lambert-93
]

def prewarm_transformers():
    for src, tgt in COMMON_PAIRS:
        _get_transformer(src, tgt)

PROJ Grid File I/O

Datum-shift grid files (NTv2 .gsb, NADCON5 .tif) can exceed 500 MB. PROJ loads grids lazily on first use, so a cold-start request to a high-accuracy CRS pair will incur a disk I/O spike. Mitigate by:

  • Calling prewarm_transformers() during service startup rather than on the first user request.
  • Mounting PROJ data to a fast local NVMe volume in containerised deployments — avoid network-attached storage for grid files.
  • Setting PROJ_NETWORK=OFF if you do not want PROJ to fetch CDN grid files at runtime (the CDN download is synchronous and adds latency on cold paths).

Request Batching for WMTS

WMTS Tile Matrix Sets Explained details how Tile Matrix Sets encode CRS implicitly, eliminating per-request CRS parameters. When building a tile seeder, resolve the TMS CRS once at startup and reuse the Transformer for the entire tile generation batch rather than re-resolving per tile. For a global EPSG:3857 deployment, Configuring Tile Matrix Sets for Global WMTS Deployments covers the tile extent and scale denominator calculations that feed into BBOX derivation.

Gotchas and Frequently Asked Questions

Why do my WMS 1.3.0 tiles appear in the ocean when I use EPSG:4326?

WMS 1.3.0 enforces the EPSG:4326 axis order (latitude first). If your client sends BBOX=lon_min,lat_min,lon_max,lat_max (the WMS 1.1.1 order), the server interprets the values as lat_min,lon_min,lat_max,lon_max. A bounding box that should cover Western Europe (roughly -10,50,2,59) gets interpreted as latitude -10 to 2, longitude 50 to 59 — a region in the Indian Ocean. Switch to CRS:84 to keep lon,lat ordering in 1.3.0 requests, or apply the axis swap in build_wms_bbox.

Can I use EPSG:900913 or EPSG:3785 with modern OGC services?

EPSG:900913 (Google Maps legacy code) and EPSG:3785 (deprecated Web Mercator variant) are not present in current EPSG registries. Many modern servers will return InvalidSRS or InvalidParameterValue for these codes. Map them to EPSG:3857 before constructing any OGC request. PROJ itself also normalises these when reading older dataset files.

Are pyproj Transformer objects safe to share across threads and async tasks?

Yes. pyproj.Transformer objects are thread-safe from pyproj 3.0 onward. You can safely store them in a module-level lru_cache and use them concurrently across threads, asyncio tasks, and worker processes. The underlying PROJ context is managed per-thread internally.

How do I detect whether a WMS server advertises CRS:84 support?

Parse the GetCapabilities response and look for <CRS>CRS:84</CRS> (WMS 1.3.0) or <SRS>CRS:84</SRS> (WMS 1.1.1) elements inside the root Layer element. If present, you can use CRS:84 in all GetMap requests and avoid the lat,lon axis swap entirely. How to Parse OGC WMS GetCapabilities XML in Python shows the lxml XPath patterns for extracting supported CRS lists.

What causes sub-metre positional errors after transforming between national grids?

Transformations between national datums (e.g., OSGB36 to ETRS89, or NAD27 to NAD83) require datum-shift grid files to achieve sub-metre accuracy. If the proj-data package is not installed or the PROJ_DATA environment variable is misconfigured, PROJ falls back to a low-accuracy Helmert (7-parameter) transform and silently returns a result that may be off by 0.5–10 m depending on the region. ProjError is raised only for outright grid-file absence, not for low-accuracy fallbacks. Enable always_xy=True and check Transformer.get_last_used_operation() to confirm grid-based paths are active.


Back to OGC Standards Architecture & Service Fundamentals

Related