Standardizing on EPSG:4326 Across Domains

Declaring a single canonical CRS is easy; making every domain actually store, reproject, and deliver in it without silent axis-order bugs is the real work. This operation sits under CRS Governance and Reprojection Standards within Geospatial Data Mesh Fundamentals, and it walks the concrete rollout: name EPSG:4326 as the canonical storage CRS, reproject legacy EPSG:32633 and EPSG:3857 sources to it at ingest, enforce the choice with a contract check at the boundary, and get lon/lat axis order right so the authority’s lat/lon convention never leaks into storage. The end state is that every interior geometry is directly comparable, and a producer that ships a non-EPSG:4326 payload is rejected before it can pollute a cross-domain join — the same gate discipline that Data Contracts for Spatial Products applies to schema and semantics.

Figure — The four-step rollout: declare EPSG:4326 canonical, reproject legacy sources at ingest, enforce with a contract check, and verify with ogrinfo, with axis order asserted throughout.

Four-step rollout for standardizing on EPSG:4326 across domains A left-to-right sequence of four steps. Step one declares EPSG:4326 as the canonical storage CRS in lon/lat order. Step two reprojects legacy EPSG:32633 and EPSG:3857 sources to EPSG:4326 at ingest. Step three enforces the choice with a contract check whose gate rejects any non-4326 payload. Step four verifies the result with an ogrinfo SRS check. A note beneath states that lon/lat order is asserted at every ingest by forcing traditional GIS order so the EPSG:4326 authority lat/lon ordering never leaks into storage. 1 · Declare canonical EPSG:4326 · lon/lat 2 · Reproject legacy 32633 / 3857 → 4326 3 · Enforce contract gate rejects non-4326 4 · Verify ogrinfo SRS check Axis order: assert lon/lat at ingest by forcing traditional GIS order so EPSG:4326 authority lat/lon ordering never leaks into storage

Prerequisites

Requirement Value / Assumption Notes
Geospatial toolchain GDAL/OGR >= 3.6 (ogr2ogr, ogrinfo, gdalsrsinfo) Reprojection and SRS verification
Catalog store PostgreSQL/PostGIS >= 3.3 Canonical interior storage in EPSG:4326
Policy engine OPA >= 0.60 (opa eval) Contract gate that rejects non-4326 payloads
Canonical CRS EPSG:4326, axis order lon_lat Interior storage; never overridden per domain
Legacy sources EPSG:32633 (UTM 33N), EPSG:3857 (web mercator) Reprojected at ingest, never stored natively
Access role crs-migration-admin (RBAC) Required to run the ingest reprojection sweep
Environment PGURI, SRC_DIR, OGR_CT_FORCE_TRADITIONAL_GIS_ORDER=YES Injected by the orchestrator, never hard-coded

Step-by-Step Implementation

Roll out in order: declare the CRS, migrate legacy data into it, enforce it at the gate, then prove axis order is correct. Each step has a verification command before the next.

1. Declare EPSG:4326 as the canonical storage CRS

Codify the choice as configuration the whole mesh reads, not tribal knowledge. The declaration names the canonical CRS, fixes axis order, and lists the source projections allowed to be reprojected in.

yaml
# crs-standard.yaml — mesh-wide CRS declaration
canonical_crs: "EPSG:4326"
canonical_axis_order: "lon_lat"          # traditional GIS order, not authority lat/lon
source_crs_allowlist: [4326, 3857, 4269, 32610, 32611, 32633]
reproject_boundaries: [ingest, delivery] # interior reprojection prohibited
min_precision_decimals: 6                # ~0.11 m; guards round-trip loss

Verify the declaration parses and pins exactly one canonical CRS:

bash
python3 -c 'import yaml,sys; d=yaml.safe_load(open("crs-standard.yaml")); \
  assert d["canonical_crs"]=="EPSG:4326" and d["canonical_axis_order"]=="lon_lat"; \
  print("canonical CRS declared: EPSG:4326 lon/lat")'

2. Reproject legacy EPSG:32633 / EPSG:3857 data at ingest

Sweep each legacy source into EPSG:4326, setting the source CRS explicitly rather than trusting the file, and forcing lon/lat order. The loop is idempotent — a source already at EPSG:4326 is copied unchanged, and re-running never double-reprojects.

bash
#!/usr/bin/env bash
set -euo pipefail
export OGR_CT_FORCE_TRADITIONAL_GIS_ORDER=YES

for f in "${SRC_DIR}"/*.gpkg; do
  SRC_EPSG=$(gdalsrsinfo -o epsg "$f" 2>/dev/null | grep -oE '[0-9]{4,5}' | head -1)
  case "$SRC_EPSG" in
    4326|3857|4269|32610|32611|32633) ;;                # allowlisted
    *) echo "SKIP: $f has non-allowlisted EPSG:${SRC_EPSG}"; continue ;;
  esac
  out="canonical_$(basename "$f")"
  # Explicit -s_srs; -t_srs is always the canonical EPSG:4326.
  ogr2ogr -f GPKG "$out" "$f" \
    -s_srs "EPSG:${SRC_EPSG}" -t_srs EPSG:4326 -lco GEOMETRY_NAME=geom
  echo "reprojected $f (EPSG:${SRC_EPSG}) -> EPSG:4326"
done

Verify every output is now EPSG:4326 with ogrinfo:

bash
for out in canonical_*.gpkg; do
  ogrinfo -so -al "$out" | grep -E 'ID\["EPSG",4326\]' >/dev/null \
    && echo "OK  $out -> EPSG:4326" || echo "FAIL $out"
done

3. Enforce the standard with a contract check at the boundary

A one-time migration is worthless without a gate that keeps new data conformant. Run a policy that admits a payload only when its declared CRS is the canonical one, so a producer that regresses to EPSG:32633 is rejected at ingress.

rego
package crs.standard

import rego.v1

default allow = false

allow if {
    input.payload.crs_epsg == 4326
    input.payload.axis_order == "lon_lat"
}

Verify the gate rejects a non-canonical payload and admits a canonical one:

bash
echo '{"payload":{"crs_epsg":32633,"axis_order":"lon_lat"}}' \
  | opa eval -I -d crs-standard.rego -f raw "data.crs.standard.allow"   # -> false
echo '{"payload":{"crs_epsg":4326,"axis_order":"lon_lat"}}' \
  | opa eval -I -d crs-standard.rego -f raw "data.crs.standard.allow"   # -> true

4. Handle axis order and load into canonical storage

EPSG:4326’s authority order is latitude-then-longitude, but GeoJSON, OGC API Features, and most tooling use longitude-then-latitude. Keep OGR_CT_FORCE_TRADITIONAL_GIS_ORDER=YES set on load so storage is unambiguously lon/lat, then load into PostGIS.

bash
export OGR_CT_FORCE_TRADITIONAL_GIS_ORDER=YES
ogr2ogr -f PostgreSQL "PG:${PGURI}" canonical_parcels.gpkg \
  -nln parcels_4326 -lco GEOMETRY_NAME=geom -nlt PROMOTE_TO_MULTI

Verify the stored SRID is 4326 and coordinates are lon/lat (longitude in the ±180 range, latitude in ±90) with a spot check:

bash
psql "$PGURI" -tAc \
  "SELECT ST_SRID(geom),
          ST_X(ST_PointOnSurface(geom)) BETWEEN -180 AND 180,
          ST_Y(ST_PointOnSurface(geom)) BETWEEN -90 AND 90
   FROM parcels_4326 LIMIT 1;"
# expect: 4326|t|t

Configuration Reference

Parameter Scope Default / Example Effect
canonical_crs Mesh EPSG:4326 The single interior storage projection
canonical_axis_order Mesh lon_lat Forces traditional GIS order on load
source_crs_allowlist Ingest [4326, 3857, 4269, 32610, 32611, 32633] Projections permitted to reproject in
OGR_CT_FORCE_TRADITIONAL_GIS_ORDER Env YES Prevents authority lat/lon inversion
min_precision_decimals Quality 6 Minimum coordinate precision retained
-nlt PROMOTE_TO_MULTI Load on Normalizes mixed single/multi geometries
reject_non_canonical Gate true Denies any payload whose crs_epsg ≠ 4326

Common Failure Modes & Fixes

Features land in the wrong hemisphere after migration. Symptom: points plot near (lat, lon) instead of (lon, lat). Root cause: OGR_CT_FORCE_TRADITIONAL_GIS_ORDER was unset, so GDAL honored EPSG:4326’s authority lat/lon order. Fix: export OGR_CT_FORCE_TRADITIONAL_GIS_ORDER=YES, re-run Step 2, and confirm with the Step 4 range check.

A legacy source is skipped silently. Symptom: some datasets never appear in canonical storage. Root cause: the source CRS was outside source_crs_allowlist, so the loop’s continue skipped it. Fix: reproject the source upstream to an allowlisted CRS, or add the projection through a governed allowlist change and re-run.

ogr2ogr reprojects but distances are still wrong. Symptom: a buffer query returns unexpected sizes. Root cause: metric operations were run against EPSG:4326 degrees. Fix: reproject to a metric CRS for the calculation only (ST_Transform(geom, 32633)), keeping storage in EPSG:4326; never store the metric copy.

Producer regresses to EPSG:32633 after go-live. Symptom: new payloads carry a non-canonical CRS. Root cause: the boundary gate was not wired into the producer’s path. Fix: attach the Step 3 policy as an admission check; a false decision returns 409 and blocks the write.

FAQ

Why store in EPSG:4326 rather than a metric CRS like EPSG:3857?

EPSG:4326 is the lingua franca that every domain can reproject into and out of without a datum shift, and it is the projection OGC API Features expects, so it makes interior geometry comparable across domains by construction. Web mercator (EPSG:3857) distorts area badly away from the equator and is only appropriate at the tile delivery port. Metric operations run against a projected copy created at query time, never against stored geometry.

Do I have to reproject EPSG:3857 sources, or can I keep them as-is?

Reproject them. Even though EPSG:3857 shares the WGS 84 datum, storing a mix of projections defeats the point of a canonical CRS: a cross-domain join would silently compare metres against degrees. Reproject EPSG:3857 to EPSG:4326 at ingest and let the tile port reproject back to EPSG:3857 at delivery.

How do I know GDAL applied lon/lat and not lat/lon order?

Run the Step 4 range check: with correct lon/lat order, ST_X falls within ±180 and ST_Y within ±90. If longitude values exceed ±90 they are being read as latitude, which means the axis order flipped. Setting OGR_CT_FORCE_TRADITIONAL_GIS_ORDER=YES on every ogr2ogr invocation makes the ordering deterministic.

Is switching a domain’s stored CRS to EPSG:4326 a breaking change for consumers?

Yes. Any change to the stored crs_epsg is a breaking change and must be released as a major version under Versioning Spatial Data Contracts with SemVer. Run the migration behind a new contract version, keep the old version queryable during the deprecation window, and let consumers cut over explicitly rather than auto-upgrading.