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.

The WKT2:2019 keyword tree A root node labelled GEOGCRS or PROJCRS branches to a DATUM node and a CS node. DATUM contains an ELLIPSOID node holding the semi-major axis and inverse flattening. The CS node holds two AXIS entries with directions and units. A separate ID node holds the authority and code, for example EPSG and 6318. The tree shows the nesting a parser must recover from the flat text. GEOGCRS / PROJCRS root CRS keyword DATUM["…"] ELLIPSOID["…"] a · 1/f · unit CS[…, 2] AXIS · AXIS · unit ID["EPSG", n] authority + code WKT2 keywords end in CRS (GEOGCRS/PROJCRS); WKT1 uses GEOGCS/PROJCS the parser recovers this nesting and rejects the WKT1 spelling

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 aa and inverse flattening 1/f1/f, the flattening is f=1/(1/f)f = 1/(1/f), the semi-minor axis is b=a(1f)b = a\,(1 - f), and the first eccentricity squared is

e2=2ff2.e^2 = 2f - f^2 .

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 aa; feeds bb and e2e^2
inverse_flattening float ≈ 298.257 1/f1/f; 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
A CRS parsed from WKT that lacked an 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
A correct datum and ellipsoid say nothing about coordinate order. Always read 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.