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.
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.
Related
- CRS Alignment for Coastal GIS Projects — the reprojection machinery that consumes the pinned, validated CRSs produced here
- Tidal Datum Transformations in Python — vertical reduction between geoid and tidal datums using the explicit compound CRS this stage guarantees
- Fixing Missing .prj Sidecar Files — reconstructing a correct EPSG when the projection sidecar is absent, then validating it here
- Pinning GDAL & PROJ Versions in Docker Images — the container build that eliminates PROJ database version drift across the fleet