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.

A Supersession Chain, and What It Costs to Ignore Four boxes in a left-to-right chain representing successive realisations of the NAD83 datum: the original 1986 adjustment, HARN in the early 1990s, CORS96, and NAD83 2011. Arrows between them are labelled with the typical horizontal difference between consecutive realisations, ranging from a few centimetres to tens of centimetres. A bracket beneath spans the whole chain and gives the cumulative difference between the oldest and newest as up to about 1.2 metres in the western United States and roughly 10 centimetres on the east coast. A note states that all four are called NAD83, that a dataset tagged only NAD83 does not say which one it is, and that PROJ will happily transform between any pair without comment. NAD83 (1986) EPSG:4269 ~0.5 m NAD83 (HARN) EPSG:4152 ~0.2 m NAD83 (CORS96) EPSG:6783 ~0.1 m NAD83 (2011) EPSG:6318 cumulative: up to ~1.2 m in the west, ~0.1 m on the east coast All four are called “NAD83”. A dataset tagged only with that name does not say which one it is, and PROJ will transform between any pair of them without comment. The differences are small enough to survive review and large enough to matter to a survey with a 5 cm budget. Which is the definition of a silent error.

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.

Relabel or Transform — Only One of Them Is a Migration A dataset tagged EPSG:4152 with coordinates computed under NAD83 HARN is shown taking two paths. The upper path relabels it to EPSG:6318: the coordinate values are identical before and after, the label now says NAD83 2011, and a marker flags that the file now makes a claim that is false by roughly twenty centimetres. The lower path transforms it: every coordinate is recomputed through the realisation difference, the values change by that amount, and the resulting file is genuinely in the new realisation. A note records that the upper path takes milliseconds and the lower one is a reprocess, and that the difference between them is invisible in every subsequent check. survey, 1998 EPSG:4152 (HARN) 438201.44, 4293150.02 relabel to EPSG:6318 438201.44, 4293150.02 unchanged — the label lies ✗ off by ~0.2 m and now undetectable transform to EPSG:6318 438201.62, 4293150.19 every coordinate recomputed ✓ genuinely NAD83(2011) a reprocess, with a manifest The upper path takes milliseconds, the lower costs a run — and nothing can tell them apart later. Leaving the old label in place is the third option, and it is more honest than the first.

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.

One Gate, Three Outcomes, No Blanket Ban Three incoming datasets meet a single ingestion check. The first carries EPSG:6318, which is current, and passes straight through. The second carries EPSG:4152, which is deprecated but appears on the archival exemption list with a recorded reason, so it passes and the exemption is written to the run log. The third carries EPSG:4267, deprecated with no exemption, and is rejected with an error naming the recommended replacement and the two ways forward. A caption notes that without the middle path the check is turned off within a week by any team holding a legitimate historical archive. EPSG:6318 EPSG:4152 EPSG:4267 ingestion gate deprecated? exempt? current — passes, nothing logged deprecated, exempt — passes the exemption and its reason go into the run log deprecated, not exempt — rejected error names the replacement and both ways forward Drop the middle path and any team with a real historical archive disables the check within a week. With it, every exception is a config line with a reason — which is the audit trail the gate exists for.

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 .prj containing full WKT2 usually names the realisation explicitly. A file carrying only EPSG:4269 is 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.

Up: Geodetic Datums & EPSG Management