Handling EPSG lookup failures in pyproj

You loop a nightly batch of county parcel exports through pyproj.CRS(prj_text) and one file blows up with pyproj.exceptions.CRSError: Invalid projection: ..., while another parses cleanly but then Transformer.from_crs(...).transform(x, y) hands back inf, inf. The geometry is fine, the coordinates are real, and the same code worked on yesterday’s file — but this jurisdiction shipped a blank sidecar .prj, a hand-edited WKT string that maps to no authority code, a datum that PROJ knows only through a grid you never installed, or an EPSG code the registry deprecated years ago. This guide answers one narrow question: how do you turn an unresolvable coordinate reference system into a known, transformable one — or quarantine it cleanly — instead of letting it corrupt a cross-jurisdiction layer? It is a runnable companion to the broader work on CRS alignment strategies that keeps the whole municipal zoning data architecture and compliance frameworks reprojection story honest across jurisdictions.

Diagnosis: why the lookup fails jump to heading

pyproj wraps the PROJ C library, and PROJ resolves a CRS in two stages that fail differently. First it parses your input — WKT, a Proj4 string, an EPSG code, or an ESRI .prj — into an internal CRS object. Then, when you build a Transformer, it searches for a transformation pipeline between two datums, which may require an external grid-shift file. A failure at either stage produces a different symptom, and treating them as one bug is why teams keep “fixing” the wrong layer.

  1. Missing or blank .prj. Shapefiles carry their CRS in a sidecar file that municipal export tools routinely emit empty, truncated, or absent. CRS.from_wkt("") raises CRSError; worse, some readers silently attach no CRS at all and downstream code assumes WGS84, planting the parcels in the Gulf of Guinea.
  2. Custom WKT with no authority code. A GIS analyst tweaked a projection in desktop software and saved WKT that describes a valid CRS but carries no AUTHORITY["EPSG", ...] node. CRS.to_epsg() returns None, so any code keyed on an integer EPSG throws a TypeError or KeyError two functions later.
  3. Deprecated or superseded EPSG code. The .prj references a code the EPSG registry retired — a datum realization that was split or renamed. PROJ still parses it but flags it deprecated, and picking the wrong replacement silently shifts coordinates by meters.
  4. Missing transformation grid. The parse succeeds, but the datum shift (say, NAD27 to NAD83, or a NADCON/HARN correction) needs a .tif grid that is not in your PROJ data directory and network grids are disabled. Transformer returns a low-accuracy ballpark pipeline or emits inf at transform time.

Reproduce the failure deterministically and label which symptom you have before touching anything:

from pyproj import CRS
from pyproj.exceptions import CRSError

def diagnose_crs(prj_text: str):
    """Classify a CRS source string into a specific failure symptom."""
    if not prj_text or not prj_text.strip():
        return None, "missing_prj"
    try:
        crs = CRS.from_user_input(prj_text)
    except CRSError as exc:
        return None, f"parse_error: {exc}"
    epsg = crs.to_epsg()            # None when there is no authority node
    deprecated = getattr(crs, "is_deprecated", False)
    return crs, f"parsed epsg={epsg} deprecated={deprecated}"

crs, status = diagnose_crs(open("king_county.prj").read())
print(status)   # e.g. "parsed epsg=None deprecated=False"  -> custom WKT case

from_user_input is the right front door because it accepts WKT, Proj4, an "EPSG:2926" string, or a bare integer, and raises the same CRSError for all of them — so you branch on the result, not the input format.

Resolution ladder for unresolved coordinate reference systems in pyproj A county file's .prj or WKT enters a parse stage using CRS.from_user_input. If parsing raises CRSError the file routes to a quarantine partition. If it parses, the CRS goes to an authority match via to_authority; when that returns no code a curated per-jurisdiction override table supplies one, and files still unresolved also route to quarantine. A resolved authority code and datum drop into a collector, which feeds a grid check against pyproj.datadir with network grids enabled, then a Transformer built with from_crs, then a round-trip verification within tolerance, and finally an immutable lineage record stamping the EPSG code and grid identifier. CRSError? no code? unresolved matched override grids ready in tolerance County file .prj · WKT from_user_input parse CRS to_authority EPSG match? override table per-jurisdiction quarantine unresolved_crs/ resolved CRS · authority code + datum grid check pyproj.datadir Transformer from_crs round-trip ± tolerance lineage record epsg + grid id
An unresolved CRS climbs a fixed ladder — parse, authority match, override, grid, verify — or drops to quarantine.

Step-by-step implementation jump to heading

Each step handles one rung of the ladder. Compose them into a single resolver in the last step.

Step 1 — Detect the unresolved CRS deterministically jump to heading

Never let an ambiguous CRS flow downstream on a truthiness check. Wrap parsing so that missing input, parse errors, and “parsed but no authority code” are three distinct, logged outcomes — because each takes a different branch next.

from dataclasses import dataclass
from pyproj import CRS
from pyproj.exceptions import CRSError

@dataclass
class CRSResult:
    crs: CRS | None
    epsg: int | None
    reason: str

def detect_crs(prj_text: str) -> CRSResult:
    """Return a parsed CRS plus a definite authority code, or a reason it is unresolved."""
    if not prj_text or not prj_text.strip():
        return CRSResult(None, None, "missing_prj")
    try:
        crs = CRS.from_user_input(prj_text)
    except CRSError as exc:
        return CRSResult(None, None, f"parse_error: {exc}")
    # to_epsg raises nothing; it returns None when confidence is too low.
    epsg = crs.to_epsg(min_confidence=70)
    if epsg is None:
        return CRSResult(crs, None, "no_authority_code")
    if crs.is_deprecated:
        return CRSResult(crs, epsg, "deprecated_code")
    return CRSResult(crs, epsg, "resolved")

Step 2 — Match to an authority code, then a curated override table jump to heading

When to_epsg() gives nothing, ask PROJ to identify the closest registered CRS with CRS.to_authority(), which returns an (authority, code) tuple at a confidence threshold you control. If even that fails — the WKT is genuinely bespoke — fall back to a curated, per-jurisdiction override table keyed on a stable identifier like the county FIPS code. The override table is authored once from the jurisdiction’s published GIS metadata, reviewed, and version-controlled; it is not a guess made per file.

# Authored from each jurisdiction's documented State Plane zone, not inferred.
JURISDICTION_EPSG = {
    "53033": 2926,   # King County, WA — WA State Plane North (ft)
    "06037": 2229,   # Los Angeles County, CA — CA Zone V (ft)
    "48453": 2277,   # Travis County, TX — TX Central (ft)
    "12086": 2236,   # Miami-Dade County, FL — FL East (ft)
}

def resolve_code(result: "CRSResult", fips: str) -> tuple[int | None, str]:
    """Resolve to a definite EPSG via PROJ identification, then a reviewed override."""
    if result.epsg is not None and result.reason == "resolved":
        return result.epsg, "authority"
    if result.crs is not None:
        auth = result.crs.to_authority(min_confidence=70)  # (auth, code) or None
        if auth and auth[0] == "EPSG":
            return int(auth[1]), "identified"
    override = JURISDICTION_EPSG.get(fips)
    if override is not None:
        return override, "override_table"
    return None, "unresolved"

Step 3 — Install or enable the transformation grids jump to heading

A resolved code still transforms wrong if the datum shift needs a grid you do not have. Inspect the candidate pipelines with TransformerGroup: best_available tells you whether the highest-accuracy operation is usable, and unavailable_operations names the missing .tif grids. Enable PROJ’s network layer to fetch them on demand from the CDN, or pre-seed them into pyproj.datadir for air-gapped runs.

import pyproj
from pyproj.transformer import TransformerGroup

def ensure_grids(source_epsg: int, target_epsg: int = 4326) -> bool:
    """Return True once a high-accuracy transform pipeline is actually available."""
    pyproj.network.set_network_enabled(active=True)   # allow grid-shift downloads
    tg = TransformerGroup(source_epsg, target_epsg)
    if tg.best_available:
        return True
    # Log exactly which grids are missing so an operator can pre-seed them.
    for op in tg.unavailable_operations:
        missing = [g.short_name for g in op.grids if not g.available]
        print(f"missing grids for {source_epsg}->{target_epsg}: {missing}")
    tg.download_grids(open_license=True)  # fetch permissively-licensed grids
    return TransformerGroup(source_epsg, target_epsg).best_available

Step 4 — Quarantine anything still unresolved jump to heading

If the ladder ends without a definite code and available grids, the file must not be transformed on a fallback assumption. Write it to a quarantine partition with everything an operator needs to fix the override table, then continue the batch. A silent WGS84 default here is how parcels end up in the wrong hemisphere.

import json, hashlib
from pathlib import Path
from datetime import datetime, timezone

def quarantine(prj_text: str, fips: str, reason: str, out_dir="unresolved_crs") -> str:
    """Persist an unresolved CRS with full context; never guess a default."""
    Path(out_dir).mkdir(exist_ok=True)
    digest = hashlib.sha256((prj_text or "").encode()).hexdigest()[:16]
    record = {
        "fips": fips,
        "reason": reason,
        "prj_sha256_16": digest,
        "prj_excerpt": (prj_text or "")[:280],
        "quarantined_at": datetime.now(timezone.utc).isoformat(),
    }
    path = Path(out_dir) / f"{fips}_{digest}.json"
    path.write_text(json.dumps(record, indent=2))
    return str(path)

The State Plane zones you will pin most often when authoring the override table:

EPSG Zone Units
2926 Washington State Plane North US feet
2229 California Zone V (LA) US feet
2277 Texas Central US feet
2236 Florida East US feet
4326 WGS84 (target) degrees

Verification & testing jump to heading

Confirm you resolved the CRS correctly rather than merely silencing the exception. Coordinate parsing and validity are the same discipline applied downstream in geospatial format conversion when CRS metadata must survive a Shapefile-to-GeoParquet round-trip.

  • Round-trip the projection. Transform a known landmark from the source EPSG to WGS84 and back, and assert the result matches the input within tolerance. Drift beyond a foot or two means the wrong zone or a missing grid, not a rounding error.
  • Bounding-box sanity. Project a parcel centroid and assert it falls inside the jurisdiction’s published extent. A point in the ocean is almost always a 4326 default masquerading as a resolved CRS.
  • Assert a definite code. Every accepted record must carry an integer EPSG and the resolver reason (authority, identified, or override_table). Reject any record whose reason is unresolved.
  • Grid availability is logged, not assumed. A healthy run records best_available=True per source zone; track the count of download_grids calls as a drift signal that a new datum entered the feed.
from pyproj import Transformer

def assert_round_trip(source_epsg: int, x: float, y: float, tol_m: float = 0.5):
    fwd = Transformer.from_crs(source_epsg, 4326, always_xy=True)
    inv = Transformer.from_crs(4326, source_epsg, always_xy=True)
    lon, lat = fwd.transform(x, y)
    x2, y2 = inv.transform(lon, lat)
    assert abs(x - x2) < tol_m and abs(y - y2) < tol_m, "round-trip drift; wrong zone/grid"

Failure recovery jump to heading

When a source defeats the ladder mid-batch, the run must stay reproducible and never poison the target layer.

  • Quarantine, never default. An unresolved CRS goes to the unresolved_crs/ partition with its FIPS, reason, and .prj hash, and the loop continues. Backfilling one reviewed override-table entry reprocesses every quarantined file for that jurisdiction deterministically.
  • Pin deprecated codes to a reviewed successor. When is_deprecated is true, map the old code to its replacement in a small, audited table rather than trusting PROJ’s automatic substitution; record both codes on the output so the shift is auditable.
  • Cache resolved codes per source. Memoize fips -> (epsg, grid_ids) so a healthy jurisdiction never re-runs identification, and an alert fires only when a previously-resolved source suddenly needs the override branch.
  • Alert on ladder depth. Emit structured logs with fips, reason, and grids_downloaded, and trigger a review when override-table or quarantine outcomes exceed a small fraction of the daily batch — a spike usually means an upstream export tool changed its .prj writer.

Frequently asked questions jump to heading

Why does CRS.to_epsg() return None on a CRS that clearly parsed?

The CRS parsed into a valid definition but carries no AUTHORITY node, or PROJ’s confidence in matching it to a registered code is below the threshold. Call CRS.to_authority(min_confidence=70) to identify the closest registered code, and if that also fails, resolve it from a reviewed per-jurisdiction override table rather than assuming a default.

My transform returns inf instead of raising — what happened?

The CRS resolved but the datum shift needs a grid-shift file that is not installed, so PROJ either falls back to a ballpark pipeline or produces infinities at the edges. Inspect TransformerGroup(src, dst).best_available, enable pyproj.network.set_network_enabled(True), and download or pre-seed the missing .tif grids named in unavailable_operations.

A shapefile has a blank or missing .prj — can pyproj guess the CRS?

No, and it should not. A blank .prj carries zero information, so any inference is a guess that will silently misplace parcels. Detect the empty sidecar explicitly, look the CRS up from the jurisdiction’s published GIS metadata, and encode that decision in the override table so it is reviewed and reproducible.

How do I handle a deprecated EPSG code without shifting coordinates?

PROJ still parses deprecated codes and exposes crs.is_deprecated. Do not rely on its automatic successor selection for legal data; map the retired code to a reviewed replacement in a small audited table, transform through an explicit grid, and stamp both the old and new codes on the output record so the datum change is fully traceable.