Parsing WKT2:2019 CRS Strings in Python
Reading a CRS back from its WKT2:2019 text — and proving it is genuinely WKT2 and not a legacy WKT1 string wearing the same file extension — is the ingress guard that keeps a cadastral pipeline honest, the parsing counterpart to the emission and inspection work in Working with EPSG and WKT2 CRS Definitions in Python under Core Transformation Fundamentals & Standards. A deliverable often arrives as a stored .prj or a WKT blob rather than an EPSG code, and accepting it without validation reintroduces exactly the axis-order and authority ambiguity that recording WKT2 was meant to remove.
Figure — the nested WKT2:2019 keyword tree a parser must recover from flat text.
The WKT2 Keyword Tree, and How It Differs from WKT1
WKT2:2019 is a nested, keyword-delimited grammar. A geographic CRS opens with GEOGCRS (a projected CRS with PROJCRS), and inside it the datum appears as DATUM, the ellipsoid as ELLIPSOID with its semi-major axis and inverse flattening, the coordinate system as CS[...] with an ordered list of AXIS entries carrying direction and unit, and finally an ID node binding an authority and code. The single most useful discriminator between the two WKT generations is the root spelling: WKT2 keywords end in CRS (GEOGCRS, PROJCRS, GEODCRS), whereas WKT1 uses the older GEOGCS and PROJCS. A validator that accepts a GEOGCS string as “WKT2” has already failed, because WKT1 is ambiguous about axis order — the property the whole exercise exists to pin down.
From the ellipsoid node a parser can immediately recover the derived quantities a projection consumes. Given the semi-major axis
Recovering these from the parsed CRS rather than hard-coding them keeps the arithmetic tied to the datum the string actually declares — the same discipline the CRS-mismatch reconciliation workflow depends on when a dataset mixes frames.
Complete Runnable Implementation
The routine below parses a WKT2:2019 string with pyproj.CRS.from_wkt, extracts the datum, ellipsoid, axis order and authority, asserts it is WKT2 rather than WKT1, and round-trips it back to WKT2 while proving the authority ID survives. It is self-contained and type-hinted, and it treats non-UTF-8 input as a hard error rather than letting a mangled datum name through.
from __future__ import annotations
import logging
from dataclasses import dataclass
from pyproj import CRS
from pyproj.enums import WktVersion
from pyproj.exceptions import CRSError
logger = logging.getLogger("wkt2")
# WKT2 CRS roots end in "CRS"; the WKT1 spellings are explicitly rejected.
WKT2_ROOTS: tuple[str, ...] = (
"GEOGCRS", "GEODCRS", "PROJCRS", "COMPOUNDCRS", "VERTCRS",
"ENGCRS", "PARAMETRICCRS", "TIMECRS", "BOUNDCRS",
)
WKT1_ROOTS: tuple[str, ...] = ("GEOGCS", "PROJCS", "GEOCCS", "LOCAL_CS")
@dataclass(frozen=True)
class ParsedWKT2:
crs: CRS
root_keyword: str
datum_name: str
ellipsoid_name: str
semi_major_m: float
inverse_flattening: float
axis_abbrevs: tuple[str, ...]
authority: str | None # "EPSG:6318" or None if no ID survived
def parse_wkt2(text: str | bytes) -> ParsedWKT2:
"""Parse and validate a WKT2:2019 CRS string.
Rejects WKT1 spellings, non-UTF-8 bytes, and strings pyproj cannot build
a CRS from. Extracts the identity-bearing components for assertion/logging.
"""
if isinstance(text, bytes):
try:
text = text.decode("utf-8") # WKT2 is UTF-8; anything else is corrupt input
except UnicodeDecodeError as exc:
raise ValueError("WKT input is not valid UTF-8") from exc
stripped = text.lstrip(" \t\r\n") # tolerate a BOM / leading whitespace
root = stripped.split("[", 1)[0].strip().upper()
if root in WKT1_ROOTS:
raise ValueError(f"Input is WKT1 ({root}), not WKT2; refuse to treat it as WKT2")
if root not in WKT2_ROOTS:
raise ValueError(f"Unrecognized WKT root keyword {root!r}")
try:
crs = CRS.from_wkt(text)
except CRSError as exc:
raise ValueError(f"pyproj could not parse the WKT: {exc}") from exc
ell = crs.ellipsoid
auth = crs.to_authority()
parsed = ParsedWKT2(
crs=crs,
root_keyword=root,
datum_name=crs.datum.name if crs.datum else "<undefined>",
ellipsoid_name=ell.name,
semi_major_m=float(ell.semi_major_metre),
inverse_flattening=float(ell.inverse_flattening or 0.0),
axis_abbrevs=tuple(ax.abbrev for ax in crs.axis_info),
authority=f"{auth[0]}:{auth[1]}" if auth else None,
)
if parsed.authority is None:
logger.warning("Parsed WKT2 carries no authority ID; it cannot be traced to a register")
return parsed
def round_trip_wkt2(parsed: ParsedWKT2, *, pretty: bool = True) -> str:
"""Re-emit the parsed CRS as WKT2:2019, asserting the authority ID survives."""
wkt = parsed.crs.to_wkt(version=WktVersion.WKT2_2019, pretty=pretty)
if parsed.authority is not None:
code = parsed.authority.split(":")[1] # e.g. "4326"
if code not in wkt:
raise ValueError("Authority ID was lost on round trip")
return wkt
Parameter Reference
| Name | Type | Units | Valid range | Notes |
|---|---|---|---|---|
text |
str | bytes |
— | a WKT2:2019 string | bytes are decoded as UTF-8; other encodings raise |
root_keyword |
str |
— | a WKT2 root | GEOGCS/PROJCS are rejected as WKT1 |
datum_name |
str |
— | published datum | the physical frame the string declares |
semi_major_m |
float |
metres | ≈ 6378137 | ellipsoid |
inverse_flattening |
float |
— | ≈ 298.257 | 0.0 denotes a sphere |
axis_abbrevs |
tuple[str, ...] |
— | ("Lat","Lon") / ("E","N") |
the parsed axis order — never assume it |
authority |
str | None |
— | e.g. EPSG:6318 |
None means no traceable ID in the string |
Minimal Worked Example
wkt2_text = (
'GEOGCRS["WGS 84",'
'DATUM["World Geodetic System 1984",'
'ELLIPSOID["WGS 84",6378137,298.257223563,LENGTHUNIT["metre",1]]],'
'CS[ellipsoidal,2],'
'AXIS["geodetic latitude (Lat)",north],'
'AXIS["geodetic longitude (Lon)",east],'
'ANGLEUNIT["degree",0.0174532925199433],'
'ID["EPSG",4326]]'
)
p = parse_wkt2(wkt2_text)
print(p.root_keyword, p.axis_abbrevs, p.authority)
# GEOGCRS ('Lat', 'Lon') EPSG:4326
print(round(p.semi_major_m, 1), round(p.inverse_flattening, 9))
# 6378137.0 298.257223563
wkt_out = round_trip_wkt2(p)
print(wkt_out.splitlines()[0])
# GEOGCRS["WGS 84",
The parsed axis order is ('Lat', 'Lon') — latitude first — recovered from the string rather than assumed, which is the whole point: the same CRS delivered as a bare EPSG:4326 would have told a reader nothing about ordering.
Validation Check
# The round-tripped WKT2 must still parse to the SAME CRS and keep its ID.
reparsed = parse_wkt2(round_trip_wkt2(p))
assert reparsed.crs == p.crs, "round trip changed the CRS identity"
assert reparsed.authority == "EPSG:4326", "authority ID lost on round trip"
assert reparsed.root_keyword.endswith("CRS"), "output is not WKT2"
logger.info("wkt2_round_trip ok %s axis=%s", reparsed.authority, reparsed.axis_abbrevs)
The equality assertion is deliberately strict ==, so a silent axis-order change during the round trip would fail the check rather than pass unnoticed.
Common Mistakes
Accepting a WKT1 string as if it were WKT2
pyproj will happily build a CRS from legacy WKT1, so from_wkt succeeding proves nothing about the version. Check the root keyword: GEOGCS/PROJCS are WKT1 and ambiguous about axis order, whereas GEOGCRS/PROJCRS are WKT2. Reject the WKT1 spelling explicitly rather than treating a parse success as validation.Losing the authority ID on the round trip
ID node re-emits without one, so the record can no longer be traced to a register. Confirm to_authority() is not None after parsing, and assert the code survives re-emission — the round_trip_wkt2 guard above raises when it does not.Ignoring axis order because the datum looked right
axis_info from the parsed CRS and carry that ordering into the consumer, or a latitude-first CRS fed longitude-first data transposes every point without raising. Non-UTF-8 input is the related trap: decode explicitly so a mangled datum name fails loudly instead of silently.Related
- Working with EPSG and WKT2 CRS definitions in Python — the parent topic on resolving codes, inspecting components, and emitting WKT2.
- Setting up high-precision coordinate reference systems — feeding a validated CRS into a transformer with the right operation.
- Handling CRS mismatches in cadastral datasets — reconciling mixed or mislabelled CRS tags across a dataset.
- Core Transformation Fundamentals & Standards — the parent reference on CRS resolution and ISO 19111 compliance.