Resolving Deprecated EPSG Codes in pyproj
CRS.from_epsg(4326) works. So does CRS.from_epsg(4269), and so does a long tail of codes the EPSG authority has since marked deprecated. They resolve, they transform, they raise nothing — and several of them now mean something measurably different from what they meant when the dataset that carries them was written. This page is the detection and migration procedure, within Geodetic Datums & EPSG Management, for a class of error that is invisible precisely because the code still works.
Root cause: a code is a pointer, and the thing it points at is maintained
An EPSG code identifies a definition in a registry that is revised several times a year. Definitions are deprecated for three different reasons and only one of them is a mistake:
- A better realisation exists. NAD83(2011) supersedes NAD83(CORS96) supersedes NAD83(HARN). Each is a refinement of the same datum, and the differences are centimetres to decimetres.
- The definition was wrong. A transposed parameter, a wrong ellipsoid, a bad area of use. These are rare and are corrected by deprecating the old code and issuing a new one.
- The definition was duplicated. Two codes described the same thing; one is retired.
pyproj exposes the flag, and almost nothing checks it:
from pyproj import CRS
for code in (4326, 4269, 4152, 3857):
crs = CRS.from_epsg(code)
print(f"EPSG:{code:<5} deprecated={crs.is_deprecated!s:<5} {crs.name}")
The is_deprecated attribute has been there for years. A pipeline that reads a .prj, resolves it, and proceeds has all the information needed to notice and does not look.
Step-by-step fix with production code
1. Detect, and follow the supersession chain
PROJ ships its registry as a SQLite database, and the supersession relation is a table in it. Reading it directly is more reliable than string-matching on names:
import logging
import sqlite3
import pyproj
from pyproj import CRS
logger = logging.getLogger(__name__)
def proj_db_path() -> str:
"""Location of the proj.db actually in use by this process."""
for d in pyproj.datadir.get_data_dir().split(";"):
import os
p = os.path.join(d, "proj.db")
if os.path.exists(p):
return p
raise FileNotFoundError("proj.db not found; PROJ_DATA is probably unset")
def replacement_for(epsg: int) -> int | None:
"""The code the authority now recommends, or None if this one is current."""
crs = CRS.from_epsg(epsg)
if not crs.is_deprecated:
return None
con = sqlite3.connect(f"file:{proj_db_path()}?mode=ro", uri=True)
try:
rows = con.execute(
"SELECT replacement_auth_name, replacement_code FROM supersession "
"WHERE superseded_auth_name = 'EPSG' AND superseded_code = ?",
(str(epsg),)).fetchall()
finally:
con.close()
codes = [int(c) for a, c in rows if a == "EPSG"]
if not codes:
logger.warning("EPSG:%d is deprecated with no recorded replacement", epsg)
return None
if len(codes) > 1:
raise ValueError(
f"EPSG:{epsg} has {len(codes)} candidate replacements {codes}; "
"the choice depends on which realisation the data was computed in "
"and cannot be made automatically")
return codes[0]
The multiple-replacement case raises rather than picking. That is deliberate: when the authority offers several successors, the right one depends on how the coordinates were produced, which is a fact about the survey and not about the code.
2. Measure the difference before deciding anything
The size of the shift decides whether this matters for a given dataset, and it varies by location:
from pyproj import Transformer
def realisation_shift_m(old: int, new: int, lon: float, lat: float) -> float:
"""Horizontal difference between two realisations at one place, in metres."""
fwd = Transformer.from_crs(f"EPSG:{old}", f"EPSG:{new}", always_xy=True)
lon2, lat2 = fwd.transform(lon, lat)
geod = pyproj.Geod(ellps="GRS80")
_, _, dist = geod.inv(lon, lat, lon2, lat2)
logger.info("EPSG:%d → EPSG:%d at (%.3f, %.3f): %.3f m", old, new, lon, lat, dist)
return dist
If the shift at the survey’s centroid is an order of magnitude below the product’s uncertainty, re-tagging is bookkeeping. If it is comparable, re-tagging changes the data and needs the same review as any other reprocess.
3. Re-tag forward, never backward
The migration rule that matters: a dataset is tagged with the realisation its coordinates were computed in, not with the realisation that is current. A 1998 survey adjusted on NAD83(HARN) is correctly labelled EPSG:4152 forever; changing the label to 6318 does not move the coordinates and asserts, falsely, that they were computed under a realisation that did not yet exist.
The correct migration for such a dataset is a transformation to the current realisation, which moves the coordinates and is a reprocess, or leaving it alone with an accurate label. What is never correct is relabelling without transforming — the same mistake as set_crs where to_crs was needed, described in repairing invalid coastline polygons for geometry.
4. Gate the boundary so it cannot come back
Migrating an archive once is a project. Keeping it migrated is a gate, and it belongs at ingestion where a bad code is cheap to reject:
class DeprecatedCRSError(ValueError):
"""Raised when incoming data carries a superseded CRS with no exemption."""
def assert_crs_acceptable(epsg: int, *, exemptions: frozenset[int] = frozenset()) -> None:
"""Reject a deprecated code at the pipeline boundary unless it is exempt."""
crs = CRS.from_epsg(epsg)
if not crs.is_deprecated or epsg in exemptions:
return
repl = None
try:
repl = replacement_for(epsg)
except ValueError as exc: # several candidates — a human must choose
raise DeprecatedCRSError(f"EPSG:{epsg} is deprecated; {exc}") from exc
hint = f" — the authority now recommends EPSG:{repl}" if repl else ""
raise DeprecatedCRSError(
f"EPSG:{epsg} ({crs.name}) is deprecated{hint}. Either transform the "
"data to the current realisation or add this code to the archival "
"exemption list with a note saying why.")
The exemption list is the part that makes the gate survivable. Without it, a team with a legitimate historical archive turns the check off within a week; with it, every exception is a line in a config file with a reason attached, which is exactly the audit trail the gate exists to produce.
Verification and acceptance test
import pytest
ALLOWED_LEGACY = {4152} # datasets we knowingly keep on their original realisation
def test_no_unexpected_deprecated_codes_in_the_catalogue(catalogue):
offenders = {}
for name, epsg in catalogue.items():
if epsg in ALLOWED_LEGACY:
continue
crs = CRS.from_epsg(epsg)
if crs.is_deprecated:
offenders[name] = (epsg, replacement_for(epsg))
assert not offenders, f"deprecated codes in use: {offenders}"
def test_the_deprecation_check_can_actually_fail():
# Feed the machinery a code that IS deprecated, so a check that silently
# stopped working cannot pass by finding nothing.
assert CRS.from_epsg(4152).is_deprecated
The second test is the fleet habit applied here: an assertion that has never rejected anything proves nothing.
Edge cases and gotchas
- The deprecation flag depends on the proj.db version, not on the code. A container built last year reports a code as current that this year’s container reports as deprecated. Pin the database version alongside the library, as pinning GDAL and PROJ versions sets out, or this check is not reproducible.
- WKT carries the realisation; a bare code sometimes does not. A
.prjcontaining full WKT2 usually names the realisation explicitly. A file carrying onlyEPSG:4269is genuinely ambiguous, and the ambiguity is resolved by the survey report, not by the file. - Deprecated does not mean unusable. For a historical archive the deprecated code is the correct code. The gate should flag deprecated codes on incoming data and permit them on archival data with a recorded exemption — a blanket ban produces a migration that falsifies the archive.
Related
- Fixing Missing .prj Sidecar Files — the adjacent case where there is no code at all
- Choosing a UTM Zone for a Cross-Zone Survey — the other CRS decision that carries a stated, bounded error
- Geodetic Datums & EPSG Management — the registry and validator this check plugs into