Ask pyproj for the coordinate system’s first axis and read its direction: north or south means latitude-first, east or west means longitude-first. CRS.from_authority("EPSG", 4326).axis_info[0].direction is the whole answer, it is correct for every code in the registry, and it removes the hard-coded set that every spatial codebase accumulates and nobody maintains.
Every project that talks to OGC services eventually grows a constant named something like LAT_FIRST_CRS holding four or five identifiers. It is correct for the systems the team has met and wrong for the next one. The EPSG registry contains thousands of geographic systems, a substantial fraction of which are latitude-first, and new ones are added with every release.
The information is not obscure — it is in the registry, and it is in the PROJ database that pyproj already ships with. A coordinate system definition includes its axes in order, each with a name, an abbreviation and a direction. The first axis pointing north or south is exactly what “latitude-first” means, stated by the authority rather than inferred by a developer.
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from pyproj import CRS
from pyproj.exceptions import CRSError
NORTH_SOUTH = {"north", "south"}
@dataclass(frozen=True)
class AxisProfile:
authority: str
code: str
first_axis: str # e.g. "Geodetic latitude"
first_direction: str # e.g. "north"
lat_first: bool
is_geographic: bool
unit: str
def order(self) -> str:
return "latitude, longitude" if self.lat_first else "easting/longitude first"
@lru_cache(maxsize=512)
def axis_profile(authority: str, code: str | int) -> AxisProfile:
"""Read the authority's own axis definition for one reference system.
Cached because a registry definition is immutable for the lifetime of
the installed PROJ database, and constructing a CRS is not free.
"""
try:
crs = CRS.from_authority(authority.upper(), str(code))
except CRSError as exc:
raise ValueError(f"{authority}:{code} is not in the PROJ database") from exc
first = crs.axis_info[0]
direction = (first.direction or "").lower()
return AxisProfile(
authority=authority.upper(),
code=str(code),
first_axis=first.name,
first_direction=direction,
lat_first=direction in NORTH_SOUTH,
is_geographic=crs.is_geographic,
unit=first.unit_name,
)
def order_for(identifier: str, spelling_is_authoritative: bool) -> bool:
"""True when coordinates for this request must be written latitude-first.
Two independent facts decide it: what the authority says about the
system, and whether the identifier spelling promises to honour that.
The short "EPSG:4326" form conventionally does not.
"""
authority, _, code = identifier.rpartition(":")
authority = authority.replace("urn:ogc:def:crs:", "").strip(":") or "EPSG"
profile = axis_profile(authority, code)
return profile.lat_first and spelling_is_authoritative
def swap_if_needed(coords: list[tuple[float, float]], lat_first: bool):
"""Normalise a coordinate list to longitude-first, once, at the edge."""
return [(y, x) for x, y in coords] if lat_first else list(coords)
if __name__ == "__main__":
for code in (4326, 4258, 3857, 2056, 27700, 32633):
profile = axis_profile("EPSG", code)
print(f"EPSG:{code:<6} {profile.first_axis:<22} "
f"{profile.first_direction:<6} lat_first={profile.lat_first}")
axis_info is an ordered tuple, and order is the whole point. crs.axis_info[0] is the first axis as the authority defines it, carrying a human-readable name, an abbreviation, a direction and a unit. Reading the direction rather than matching on the name is deliberate: names vary between “Geodetic latitude”, “Lat” and localised forms, whereas the direction vocabulary is fixed.
Two independent facts decide the answer. order_for deliberately takes a second argument, because the authority’s axis order only applies when the identifier spelling promises to honour it. A request naming urn:ogc:def:crs:EPSG::4326 is latitude-first; one naming EPSG:4326 conventionally is not, even though both resolve to the same registry entry. Collapsing those two facts into one function that takes only the code is the mistake that makes axis handling feel unpredictable.
Cache aggressively. A CRS construction parses a definition and costs around a millisecond. Wrapping the profile lookup in lru_cache makes the second and subsequent calls free, which matters on a request path handling hundreds of calls a second.
Normalise once, at the edge. swap_if_needed exists to be called in exactly one place — the request boundary — after which everything internal is longitude-first. Scattering swap decisions through the codebase is how the mismatch symptoms become impossible to attribute.
Run the module and check the registry answers against systems you know:
python axis_profile.py
EPSG:4326 Geodetic latitude north lat_first=True
EPSG:4258 Geodetic latitude north lat_first=True
EPSG:3857 Easting east lat_first=False
EPSG:2056 Easting east lat_first=False
EPSG:27700 Easting east lat_first=False
EPSG:32633 Easting east lat_first=False
The interesting entry is EPSG:4258 — ETRS89, the European geographic system — which is latitude-first and is exactly the kind of code that never makes it into a hand-maintained list until a European client reports that everything is in the wrong hemisphere.
OGC:CRS84 is not EPSG:4326. CRS84 is a distinct definition whose first axis is longitude, and it is the system RFC 7946 GeoJSON mandates. Resolving it through the same function works — pyproj knows the OGC authority — and returns lat_first=False, which is correct and which is why GeoJSON never has an axis question.
Protocol version can override the authority. WMS 1.1.1 is longitude-first for every reference system regardless of what the registry says; only 1.3.0 defers to the authority. The registry lookup answers “what does the authority define”, and the protocol layer decides whether that answer applies — a separation worth keeping explicit.
Three-dimensional systems have three axes. A compound system such as EPSG:4979 has latitude, longitude and ellipsoidal height. Reading only the first axis still gives the right horizontal answer, but code that assumes exactly two axes will mis-handle the height ordinate — the same trap as srsDimension in GML parsing.
The PROJ database version matters. Codes are added and deprecated between EPSG releases, so a system resolvable on a developer machine may not resolve on an older container image. Pinning the PROJ version alongside the application, and asserting a known code resolves at startup, catches it before a request does.
Because the set is only stable until someone integrates a new data source. The hard-coded list is also invisible to review — it looks like configuration, so nobody questions it — whereas a registry lookup is self-evidently correct and needs no maintenance. The runtime cost, once cached, is zero.
For services that implement the specification correctly, yes: WMS 1.3.0, WFS 1.1.0 and later, and the authority identifier forms all defer to the registry definition, which is what pyproj reads. Where a service disagrees, it is the service that is non-conformant, and the correct response is to record the deviation per-endpoint rather than to change your general rule.
Treat it as unsupported. A code the PROJ database does not know is a code you cannot transform to or from, so accepting it means accepting coordinates you cannot place. Rejecting it with a specific error, as the whitelist guide describes, is both honest and actionable.
The direction vocabulary — north, south, east, west, up, down — comes from the ISO 19111 model and is stable. What changes between PROJ database versions is which codes exist and, occasionally, a corrected definition. Pin the version and assert a couple of known profiles at startup if that matters to you.
Back to SRS and Coordinate Reference System Handling
Related