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.
- 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("")raisesCRSError; worse, some readers silently attach no CRS at all and downstream code assumes WGS84, planting the parcels in the Gulf of Guinea. - 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()returnsNone, so any code keyed on an integer EPSG throws aTypeErrororKeyErrortwo functions later. - Deprecated or superseded EPSG code. The
.prjreferences 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. - Missing transformation grid. The parse succeeds, but the datum shift (say, NAD27 to NAD83, or a NADCON/HARN correction) needs a
.tifgrid that is not in your PROJ data directory and network grids are disabled.Transformerreturns a low-accuracy ballpark pipeline or emitsinfat 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.
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
4326default masquerading as a resolved CRS. - Assert a definite code. Every accepted record must carry an integer EPSG and the resolver reason (
authority,identified, oroverride_table). Reject any record whose reason isunresolved. - Grid availability is logged, not assumed. A healthy run records
best_available=Trueper source zone; track the count ofdownload_gridscalls 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.prjhash, 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_deprecatedis 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, andgrids_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.prjwriter.
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.
Related jump to heading
- Parent topic: CRS Alignment Strategies
- Section overview: Municipal Zoning Data Architecture & Compliance Frameworks
- Geospatial Format Conversion — preserving CRS metadata across format round-trips
- Schema Validation & Data Quality Checks — gating CRS mismatches before they corrupt a layer
- Data Lineage & Provenance Tracking — stamping the resolved EPSG and grid onto every record