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.
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.
# 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:
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.
#!/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:
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.
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:
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.
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:
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.
Related
- CRS Governance and Reprojection Standards — the governance model this rollout implements
- Data Contracts for Spatial Products — the contract surface the CRS gate enforces
- Versioning Spatial Data Contracts with SemVer — why a stored-CRS change is a major version bump
- Metadata Cataloging for Raster/Vector — records the declared CRS after migration