Geodetic Datums & EPSG Management

Operational intent: This page sits inside the Marine Spatial Data Fundamentals & Architecture domain and governs how a fleet of marine pipelines agrees on what the coordinates mean. An EPSG code on a file is a promise about a geodetic datum, an ellipsoid, an axis order, and — for vertical data — a zero surface. When those promises are implicit, under-specified, or interpreted by different PROJ database versions in different containers, coordinates that look identical resolve to positions one to two metres apart. In coastal engineering that is the difference between a chart datum sounding and a flooded quay. This guide defines the horizontal and vertical datum landscape a marine pipeline must track — WGS84 realizations such as G2139, ITRF versus local datums, the NAD83/WGS84 offset, and the ellipsoidal/geoid/tidal vertical distinction — and provides a production registry that resolves, validates, and pins EPSG identifiers and PROJ versions across the fleet. It is the datum-governance layer beneath the reprojection mechanics in CRS alignment for coastal GIS projects: that page moves coordinates correctly; this page guarantees the codes it is told to move between are unambiguous.


EPSG Resolution and Compound-CRS Construction An input EPSG code enters a resolution and validation stage that checks the authority, rejects deprecated or ambiguous codes, and pins the PROJ and EPSG database versions. Validated codes split into a horizontal branch (for example EPSG 32618, WGS84 UTM zone 18N, with an axis-order check) and a vertical branch (for example EPSG 5703 NAVD88 height, or a tidal MLLW datum). The two branches combine in a compound-CRS build node that emits a pinned compound CRS such as EPSG 32618 plus 5703 together with a provenance record naming the PROJ and EPSG versions. Input EPSG code "EPSG:32618" Resolve + validate authority = EPSG? deprecated? → reject ambiguous? → reject pin PROJ + EPSG db raise on failure Horizontal CRS EPSG:32618 · UTM 18N axis order checked always_xy=True Vertical CRS EPSG:5703 · NAVD88 h or MLLW / LAT tidal explicit, never assumed Compound CRS 32618+5703 + provenance record PROJ 9.4 · EPSG db 11.004

Reference Table of Common Marine EPSG Codes

The horizontal datum a marine dataset uses is rarely stated with enough precision. “WGS84” is not one datum but a family of realizations — the frame has been re-realized several times, most recently as WGS84 (G2139), and those realizations differ from each other by centimetres and from the original 1984 frame by more than a metre. For most GIS purposes WGS84 is treated as coincident with the current ITRF (International Terrestrial Reference Frame), because that is how the GPS control segment maintains it. NAD83, by contrast, is fixed to the stable North American plate and has drifted away from ITRF/WGS84 by one to two metres over the past decades — a plate-tectonic offset, not a rounding error. Conflating EPSG:4326 (WGS84) with EPSG:4269 (NAD83) is therefore a real horizontal error that survey-grade work cannot absorb.

Vertical referencing adds a second axis of ambiguity. An ellipsoidal height (from GNSS) measures distance from the reference ellipsoid; a geoid/orthometric height (NAVD88 via the GEOID18 model) measures distance from mean sea level’s equipotential surface; a tidal datum (MLLW, MHW, LAT) measures distance from a statistical tidal surface. These differ by tens of metres in places and are never interchangeable. The table records the codes a US coastal pipeline handles most; substitute regional UTM zones and tidal realizations as your footprint requires.

EPSG Name Type When to use
4326 WGS 84 (geographic 2D, lat/lon) Horizontal geographic Web/GPS interchange; treat as current ITRF/WGS84 realization; NOT interchangeable with NAD83
4269 NAD83 (geographic 2D) Horizontal geographic US federal/agency vector data; ~1-2 m from WGS84 — never silently equate to 4326
4979 WGS 84 (geographic 3D, lat/lon/ellipsoidal h) Horizontal + ellipsoidal Z Raw GNSS fixes carrying ellipsoidal height before geoid/tidal reduction
326NN WGS 84 / UTM zone NN N Projected horizontal Metric survey/analysis grids; pick zone by centroid longitude (e.g. 32618 = zone 18N)
3857 WGS 84 / Pseudo-Mercator Projected horizontal Web tiles and slippy maps ONLY; unsuitable for area/distance or survey work
5703 NAVD88 height Vertical (geoid/orthometric) US topographic/inland elevation referenced to GEOID18
5866 MLLW depth (compound component) Vertical (tidal) Hydrographic soundings and chart datum for US waters
5714 MSL height Vertical (tidal/mean) Mean-sea-level referenced products; global model outputs
9518 LAT (Lowest Astronomical Tide) height Vertical (tidal) IHO chart datum outside the US; safety-of-navigation depths
6318 NAD83(2011) (geographic 2D) Horizontal geographic Modern US survey control; carries an explicit realization/epoch

Where a dataset combines horizontal and vertical, the correct representation is a compound CRS — for example EPSG:32618+5703 pairs UTM zone 18N with NAVD88 height. The mechanics of transforming between such compound CRSs, including geoid and tidal grid handling, are covered in CRS alignment and, for the tidal reduction specifically, in tidal datum transformations in Python.

A CRS Registry and Validator in Python

Ad-hoc CRS.from_epsg(code) calls scattered through a codebase are where datum errors hide. The registry below centralises the contract: every code is resolved against the EPSG authority, checked for deprecation and ambiguity, and stamped with the PROJ and EPSG database versions in force. It raises rather than guessing, and it builds validated compound CRSs. Run it once at the ingestion boundary and again in CI so a container upgrade cannot silently change what a code means.

"""
crs_registry.py — resolve, validate, and pin marine CRS identifiers.

Dependencies:
    pip install "pyproj>=3.6" packaging

Every method raises on ambiguity rather than returning a best guess: a wrong
datum is worse than a hard failure in a survey pipeline.
"""
from __future__ import annotations

import logging
from dataclasses import dataclass, field

import pyproj
from packaging.version import Version
from pyproj import CRS
from pyproj.database import query_crs_info
from pyproj.exceptions import CRSError

logger = logging.getLogger(__name__)

# Minimum PROJ version whose EPSG database carries the datum-ensemble and
# vertical-grid definitions this fleet relies on. Bump deliberately.
MIN_PROJ_VERSION: Version = Version("9.2.0")


@dataclass(frozen=True)
class PinnedCRS:
    """A validated CRS plus the toolchain versions that resolved it."""

    epsg: int
    wkt: str
    is_vertical: bool
    axis_order: tuple[str, ...]
    proj_version: str
    epsg_db_version: str
    notes: list[str] = field(default_factory=list)


class CRSRegistry:
    """
    Central resolver/validator for every EPSG code entering the pipeline.

    Parameters
    ----------
    allow_deprecated
        If False (default), resolving a deprecated EPSG code raises.
    """

    def __init__(self, allow_deprecated: bool = False) -> None:
        self._allow_deprecated = allow_deprecated
        self._proj_version = Version(pyproj.proj_version_str)
        self._epsg_db_version = self._read_epsg_db_version()

        if self._proj_version < MIN_PROJ_VERSION:
            raise RuntimeError(
                f"PROJ {self._proj_version} is older than the pinned minimum "
                f"{MIN_PROJ_VERSION}. Datum grids and ensembles may differ across "
                "containers — rebuild the image with a newer PROJ."
            )
        logger.info(
            "CRSRegistry ready — PROJ %s, EPSG db %s",
            self._proj_version, self._epsg_db_version,
        )

    @staticmethod
    def _read_epsg_db_version() -> str:
        """Return the EPSG registry version embedded in the PROJ database."""
        for authority, code, _name, *_ in query_crs_info(auth_name="EPSG"):
            # The authority metadata is exposed via CRS; probe a stable code.
            crs = CRS.from_epsg(4326)
            for meta in crs.list_authority():
                if meta.auth_name == "EPSG":
                    return meta.version or "unknown"
            break
        return "unknown"

    def resolve(self, code: int | str) -> PinnedCRS:
        """
        Resolve and validate a single EPSG code.

        Raises
        ------
        CRSError
            If the code does not resolve or is not EPSG-authority.
        ValueError
            If the code is deprecated (and not allowed) or ambiguous.
        """
        try:
            crs = CRS.from_epsg(int(code)) if str(code).isdigit() else CRS.from_user_input(code)
        except CRSError as exc:
            raise CRSError(f"Cannot resolve CRS from {code!r}: {exc}") from exc

        authorities = {m.auth_name for m in crs.list_authority()}
        if "EPSG" not in authorities:
            raise CRSError(f"{code!r} does not resolve to an EPSG-authority CRS ({authorities}).")

        if crs.is_deprecated and not self._allow_deprecated:
            raise ValueError(
                f"EPSG:{crs.to_epsg()} is deprecated. Use its successor code; "
                "deprecated definitions drift between PROJ database versions."
            )

        notes: list[str] = []
        if self._is_ambiguous_realization(crs):
            notes.append(
                "Datum is a WGS84/ITRF ensemble; positional accuracy limited to ~2 m "
                "unless a specific realization (e.g. G2139) and epoch are supplied."
            )

        axis_order = tuple(ax.abbrev for ax in crs.axis_info)
        epsg = crs.to_epsg()
        if epsg is None:
            raise ValueError(f"{code!r} resolved but has no canonical EPSG code; too ambiguous to pin.")

        logger.info("Resolved EPSG:%s (%s), axis order %s", epsg, crs.name, axis_order)
        return PinnedCRS(
            epsg=epsg,
            wkt=crs.to_wkt(),
            is_vertical=crs.is_vertical,
            axis_order=axis_order,
            proj_version=str(self._proj_version),
            epsg_db_version=self._epsg_db_version,
            notes=notes,
        )

    @staticmethod
    def _is_ambiguous_realization(crs: CRS) -> bool:
        """
        Flag datum *ensembles* (e.g. plain WGS84) whose realization is unstated.
        These carry a built-in positional uncertainty of up to ~2 m.
        """
        datum = crs.datum
        if datum is None:
            return False
        return "ensemble" in datum.type_name.lower() or datum.name.strip().upper() in {
            "WORLD GEODETIC SYSTEM 1984 ENSEMBLE",
            "WORLD GEODETIC SYSTEM 1984",
        }

    def build_compound(self, horizontal: int | str, vertical: int | str) -> PinnedCRS:
        """
        Validate a horizontal and a vertical code and fuse them into a compound CRS.

        Raises
        ------
        ValueError
            If the horizontal code is vertical, the vertical code is not vertical,
            or the fused compound CRS fails to construct.
        """
        h = self.resolve(horizontal)
        v = self.resolve(vertical)

        if h.is_vertical:
            raise ValueError(f"Horizontal slot got a vertical CRS: EPSG:{h.epsg}.")
        if not v.is_vertical:
            raise ValueError(
                f"Vertical slot got EPSG:{v.epsg}, which is not a vertical CRS. "
                "Refusing to assume ellipsoidal height."
            )

        try:
            compound = CRS.from_user_input(f"EPSG:{h.epsg}+{v.epsg}")
        except CRSError as exc:
            raise ValueError(
                f"Cannot build compound CRS EPSG:{h.epsg}+{v.epsg}: {exc}"
            ) from exc

        logger.info("Built compound CRS EPSG:%s+%s", h.epsg, v.epsg)
        return PinnedCRS(
            epsg=h.epsg,  # canonical horizontal EPSG; vertical recorded in notes
            wkt=compound.to_wkt(),
            is_vertical=False,
            axis_order=tuple(ax.abbrev for ax in compound.axis_info),
            proj_version=str(self._proj_version),
            epsg_db_version=self._epsg_db_version,
            notes=[*h.notes, *v.notes, f"compound: horizontal {h.epsg} + vertical {v.epsg}"],
        )

Usage at the ingestion boundary looks like this:

registry = CRSRegistry()

# Fails loudly if a legacy feed hands us NAD83 where WGS84 is required.
horizontal = registry.resolve("EPSG:32618")

# Refuses to proceed without an explicit vertical datum.
compound = registry.build_compound("EPSG:32618", "EPSG:5703")
logger.info("Pinned compound WKT:\n%s", compound.wkt)

Validation Gates and Quality Control

Three gates run before any CRS is admitted to the pipeline. Each raises; none warns-and-continues.

Gate 1 — Authority and version pinned. Every admitted CRS must resolve to the EPSG authority and carry the PROJ and EPSG database versions in its provenance. A CRS whose epsg_db_version differs from the pipeline’s pinned value is rejected, because the same code can resolve to a different transformation pathway under a different database.

PINNED_EPSG_DB = "11.004"  # the version this fleet is validated against


def assert_version_pinned(pinned, registry: CRSRegistry) -> None:
    """Raise if the resolving toolchain does not match the pinned versions."""
    if pinned.epsg_db_version not in {PINNED_EPSG_DB, "unknown"}:
        raise RuntimeError(
            f"EPSG db version {pinned.epsg_db_version} != pinned {PINNED_EPSG_DB}. "
            "Containers disagree on datum definitions; rebuild against the pinned PROJ."
        )
    logger.info("Version gate passed: EPSG db %s", pinned.epsg_db_version)

Gate 2 — Axis order checked. Geographic EPSG codes declare latitude before longitude. Any transformer built from a resolved CRS must use always_xy=True, and the registry records the declared axis order so a mismatch surfaces before coordinates are moved. The dedicated guide to debugging axis-order inversion in pyproj walks through the failure end to end.

Gate 3 — Vertical datum explicit. No dataset carrying a Z value is admitted with an implied vertical reference. Either the vertical CRS is named and a compound CRS is built, or the pipeline records the height as ellipsoidal (EPSG:4979) and forbids downstream tidal reduction until a datum is supplied.

def assert_vertical_explicit(pinned) -> None:
    """Raise if a compound CRS was expected but the vertical component is absent."""
    if not any(n.startswith("compound:") for n in pinned.notes):
        raise ValueError(
            "Dataset has elevation/depth values but no explicit vertical datum. "
            "Build a compound CRS or tag height as ellipsoidal (EPSG:4979)."
        )
    logger.info("Vertical-datum gate passed.")

Common Failure Modes and Diagnosis

1. NAD83 / WGS84 Conflation (~1-2 m Silent Offset)

Symptom: Vector data from a federal agency (NAD83, EPSG:4269) overlaid on GPS or web-tile data (WGS84, EPSG:4326) is consistently shifted by roughly a metre; the offset is systematic, directional, and survives every reprojection.

Root cause: Code that treats 4269 and 4326 as equivalent, or a transformer built without a datum-ensemble-aware operation, applies a null shift. The two datums differ by the accumulated North-American plate motion relative to ITRF.

Diagnosis: Transform a known control point between the two datums and measure the residual; a ~1-2 m planar offset confirms conflation. Fix by resolving each code through the registry (which never equates them) and letting PROJ select the proper datum transformation with installed grids.

from pyproj import Transformer

t = Transformer.from_crs("EPSG:4269", "EPSG:4326", always_xy=True)
dx, dy = t.transform(-75.0, 38.5)
logger.info("NAD83→WGS84 shift at test point: (%.3f, %.3f)", dx + 75.0, dy - 38.5)

2. Deprecated EPSG Code

Symptom: A code resolves on one machine and raises or resolves differently on another; QC flags a datum whose parameters changed between releases.

Root cause: The EPSG registry deprecates and supersedes codes. A deprecated definition may be retained for back-compatibility but its transformation pathway can shift between PROJ database versions.

Diagnosis: CRS.from_epsg(code).is_deprecated is True. The registry’s resolve raises on this by default; migrate to the successor code the EPSG registry names and re-pin.

3. PROJ Database Version Drift Across Containers

Symptom: The identical pipeline produces coordinates that differ by centimetres to decimetres depending on which container image ran it; CI is green locally but a rebuilt worker disagrees.

Root cause: Different PROJ builds ship different EPSG databases and different bundled datum grids. A transformation that selects a grid-based operation in one build may fall back to a coarser operation in another when the grid is absent.

Diagnosis: Log pyproj.proj_version_str and the EPSG db version (Gate 1) from every container and compare. The structural remedy is to pin the PROJ and GDAL versions and the grid set in the image build — the Pinning GDAL & PROJ Versions in Docker Images guide covers the Dockerfile mechanics and grid provisioning.

4. Axis-Order Inversion

Symptom: Transformed coordinates land in the wrong hemisphere or on another continent; the offset is enormous, not subtle.

Root cause: A transformer built without always_xy=True honours the EPSG-declared latitude-first axis order for geographic CRSs, silently transposing every coordinate pair.

Diagnosis: Transform a known point and check the sign/magnitude. Always construct transformers with always_xy=True; the debugging axis-order inversion in pyproj guide reproduces and fixes the full case.

Pipeline Integration and Downstream Handoff

The registry runs at the ingestion boundary of every service in the fleet. Each admitted dataset is tagged with a PinnedCRS provenance record — EPSG code, WKT, axis order, PROJ version, and EPSG database version — which travels with the data through the manifest schema shared across this domain. Downstream stages read that record and refuse any dataset whose epsg_db_version does not match their own pinned toolchain, so a datum definition cannot change meaning as data crosses a service boundary.

The most common ingestion defect the registry catches is a dataset with no CRS at all — the missing .prj sidecar problem, where a shapefile arrives without its projection file and every code path downstream guesses. The companion guide Fixing Missing .prj Sidecar Files documents how to reconstruct the correct EPSG from provenance rather than guessing, then feed it through this registry so the reconstruction is validated and pinned like any other code.

From here, validated CRSs flow into the reprojection machinery of CRS alignment for coastal GIS projects, and any vertical reduction — NAVD88 to MLLW and the like — is handled by tidal datum transformations in Python, which consumes the explicit compound CRS this stage guarantees. Because every container in the fleet is built against the same pinned PROJ and EPSG database, a coordinate that resolves in one service resolves identically in every other — which is the entire point of treating datum and EPSG management as a governed layer rather than an incidental call.


Up: Marine Spatial Data Fundamentals & Architecture