Automating CRS Transformations in ETL Pipelines for Spatial Data Archival

Uncoordinated Coordinate Reference System (CRS) normalization during batch ingestion is a primary driver of spatial data corruption in cold storage tiers, and it almost never raises an exception — it writes plausible-looking wrong coordinates that only surface in a failed spatial join months later. This page is for the data engineers, GIS archivists, and cloud architects who own a high-throughput ingestion path and need a deterministic, idempotent reprojection stage that default GDAL/OGR configurations cannot give them: the standard fallbacks guess at missing datums, swap axis order, and silently drop vertical components. It operationalises the design decisions in CRS Synchronization in Pipelines, sits inside the broader Format Conversion & Pipeline Automation discipline, and produces archival outputs whose projection is auditable on both sides of every conversion hop.

Transformation Pipeline

The ETL stage canonicalizes, transforms, validates, then commits — each step auditable:

Deterministic CRS transformation pipeline, left to right Four auditable stages: interrogate and canonicalize the source CRS (embedded metadata to WKT2:2019), apply one deterministic PROJ transform (ogr2ogr against pinned grids), validate bounds and topology (longitude/latitude envelope, multi-geometry), then commit the checksummed artifact to cold storage and the manifest. A failed gate routes the payload to quarantine rather than writing silently wrong coordinates. 1 · Interrogate + canonicalize CRS embedded → WKT2:2019 2 · Deterministic PROJ transform ogr2ogr · pinned grids 3 · Validate bounds + topology −180…180 · multi-geom 4 · Commit to cold storage checksum → manifest Any failed gate → quarantine (never a silent write)

Step-by-Step Procedure

Phase 0: Pipeline Configuration & Environment Hardening

The transformation stage must operate as a stateless, projection-aware middleware layer. Implicit GDAL/OGR fallbacks introduce non-reproducible datum shifts and silently drop vertical/horizontal components, so the first task is to pin PROJ data paths, disable on-the-fly CRS guessing, and force strict WKT2:2019 canonicalization before any payload is read.

# Pin PROJ/GDAL data dirs and disable every non-deterministic fallback.
export PROJ_DATA=/usr/share/proj          # PROJ 9.1+ name for the data dir
export PROJ_LIB=/usr/share/proj           # legacy pre-9.1 name, kept for older images
export GDAL_DATA=/usr/share/gdal
export GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR
export OGR_ENABLE_PARTIAL_REPROJECTION=NO  # never write a partially-reprojected layer
export PROJ_NETWORK=OFF                     # no runtime grid downloads in cold-storage workers

Route all spatial payloads through a dedicated CRS normalization container before partitioning. This isolates geometry transformation from attribute serialization, preventing cross-format metadata bleed, and lets the type-coercion contract in Schema Mapping & Attribute Validation run alongside reprojection rather than fighting it. Mount the PROJ database read-only so no worker node can mutate proj.db mid-run.

Phase 1: Input CRS Interrogation & Canonicalization

Resolve the source CRS from embedded metadata only — never from a connector default — and reject anything that cannot be mapped to a current authority code.

import json, hashlib, sys
import pyproj
from osgeo import gdal

gdal.UseExceptions()
INPUT = "datasets/vector/raw/parcels_region_north.shp"

src = gdal.OpenEx(INPUT, gdal.OF_VECTOR)
src_srs = src.GetLayer().GetSpatialRef()
if src_srs is None:
    sys.exit(f"QUARANTINE: {INPUT} has no embedded CRS; refusing implicit EPSG:4326")

crs = pyproj.CRS.from_wkt(src_srs.ExportToWkt())
if crs.is_deprecated or not crs.to_epsg():
    sys.exit(f"QUARANTINE: {INPUT} declares a deprecated or non-authority CRS")

# Normalize to WKT2:2019 so downstream readers never re-guess axis order.
canonical_wkt = crs.to_wkt(version="WKT2_2019")
with open(INPUT, "rb") as fh:
    src_hash = hashlib.sha256(fh.read()).hexdigest()

with open("/var/log/crs_manifest.jsonl", "a") as log:
    log.write(json.dumps({
        "file": INPUT, "status": "CANONICALIZED",
        "source_epsg": crs.to_epsg(), "sha256": src_hash,
    }) + "\n")

Writing the canonical WKT and source hash to a manifest before transformation gives you an exactly-once reconciliation record that is independent of the storage write, and an absent-CRS payload halts the run instead of inheriting a silent geographic assumption that would violate archival compliance.

Phase 2: Deterministic PROJ Transformation Execution

Apply a single, auditable reprojection targeting the archival standard CRS (EPSG:4326 for global indexing, EPSG:3857 for tiled web archives). Vector reprojection uses ogr2ogrgdalwarp is a raster utility and cannot reproject vector layers. Skip the transform entirely when source already equals target so the stage stays idempotent.

# Idempotent vector reprojection to the archival target CRS.
ogr2ogr \
  -t_srs "EPSG:4326" \
  -nlt PROMOTE_TO_MULTI \
  --config OGR_NUM_THREADS ALL_CPUS \
  -lco GEOMETRY_NAME=geom \
  -overwrite \
  datasets/vector/normalized/parcels_region_north.gpkg \
  datasets/vector/raw/parcels_region_north.shp

For datum changes (for example NAD27 → NAD83) the correct .gsb/.gtx transformation grid must be present, because PROJ_NETWORK=OFF makes PROJ silently fall back to a lower-accuracy ballpark shift if the grid is missing. Pre-bundle every required grid into the container image and treat a missing grid as a hard failure:

# Fail the build if a required datum-shift grid was not baked into the image.
for grid in us_noaa_nadcon5_nad27_nad83.tif ca_nrc_ntv2_0.tif; do
  test -f "$PROJ_DATA/$grid" || { echo "FATAL: missing grid $grid"; exit 1; }
done

Which Transformation Path PROJ Chose

A transformation between two reference systems is rarely unique. PROJ ranks candidate pipelines by accuracy and availability, and the one it picks depends on which grid-shift files are installed on the machine that ran the job. The same source and target EPSG codes can therefore produce results that differ by a metre or more between two workers in the same fleet — a difference no schema check will catch, because both outputs are well-formed.

Candidate transformation pipelines and their accuracies Three ranked transformation options between the same pair of reference systems, with their accuracy and their dependency on an installed grid-shift file. The most accurate requires a grid; the fallback used when no grid is installed is forty times less accurate. EPSG:4269 (NAD83) → EPSG:4326 (WGS84) · candidates ranked by accuracy 1 · NADCON5 grid shift accuracy 0.05 m requires us_noaa_nadcon5 grid installed chosen when the grid is present — the only option that survives an audit 2 · Helmert, 7 parameters accuracy 1.5 m no grid needed · ships with PROJ 30× worse than the grid path, and silently selected in its absence 3 · null transformation accuracy 2 m treats the datums as equivalent last resort — a pipeline that lands here has silently degraded to a no-op Pin the pipeline explicitly and record it per dataset; otherwise the installed grid inventory of each worker decides your coordinates.

The defence is to stop asking for a coordinate operation by endpoints and start asking for it by name. Resolve the pipeline once, record its PROJ string and accuracy in the dataset manifest, and pass that exact string to every worker rather than letting each resolve its own. Assert on the grid inventory at container start so a missing file fails the job loudly instead of degrading the output quietly, and treat any change in the recorded pipeline between runs as a schema-level change to the archive.

Validation & Verification

Before committing to cold storage, assert coordinate bounds, geometry topology, and that the CRS authority code survived the write into the output file metadata.

# 1. Bounds must fall inside the EPSG:4326 envelope.
ogrinfo datasets/vector/normalized/parcels_region_north.gpkg -al -so | grep -i "Extent"

# 2. Serialize to the archival columnar format with topology promotion.
ogr2ogr -f Parquet \
  datasets/vector/archive/parcels_region_north.parquet \
  datasets/vector/normalized/parcels_region_north.gpkg \
  -nlt PROMOTE_TO_MULTI -lco COMPRESSION=ZSTD

# 3. Assert the embedded CRS on the FINAL artifact, not just the intermediate.
python -c "
import geopandas as gpd, pyproj
df = gpd.read_parquet('datasets/vector/archive/parcels_region_north.parquet')
assert pyproj.CRS(df.crs).equals(pyproj.CRS('EPSG:4326')), 'CRS mismatch in Parquet geo metadata'
print('Schema validation passed.')"

# 4. Checksum the immutable artifact into the audit manifest.
sha256sum datasets/vector/archive/parcels_region_north.parquet >> /var/log/crs_manifest.jsonl

Annotated expected output of step 1:

Extent: (-123.421000, 48.401000) - (-122.118000, 49.002000)

X stays within −180…180 and Y within −90…90, confirming the geometries are in longitude/latitude order rather than projected metres. If you see values in the hundreds of thousands (for example Extent: (472000, 5360000) - …), projected coordinates were written into a geographic column and the artifact must be rejected. The -lco COMPRESSION=ZSTD flag only sets a default level; tune it deliberately with ZSTD Level Configuration for Spatial Files after — never before — coordinate precision is fixed at this CRS stage.

Troubleshooting

Symptom Root cause Diagnostic & fix
Coordinates shifted ~10–100 m, bounds still valid Datum-shift grid missing under PROJ_NETWORK=OFF; PROJ used a ballpark transform ls "$PROJ_DATA"/*.tif to confirm the grid is staged; bake the .gsb/.gtx/.tif into the image and treat a missing grid as a hard error, not a downgrade
X/Y axis swapped (features mirrored across the diagonal) WKT1 vs WKT2:2019 axis-order ambiguity; writer assumed lon/lat Export WKT2_2019 and force OAMS_TRADITIONAL_GIS_ORDER / always_xy=True on every transformer; verify against a known control point
Geometry silently collapsed to POINT Mixed single/multi geometry types serialized without promotion Re-run with -nlt PROMOTE_TO_MULTI; inspect the Geometry: line from ogrinfo -al -so
proj_create_from_database: Cannot find proj.db PROJ_DATA/PROJ_LIB path wrong or DB not mounted in the container echo $PROJ_DATA && ls $PROJ_DATA/proj.db; mount the host PROJ DB read-only or bake it in, and align GDAL_DATA
Parquet geo metadata fails CRS assertion Footer carries a legacy PROJ string instead of an authority code Inspect the geo key in the Parquet footer; inject WKT2:2019 via a pyarrow schema update before the archival write

Operational note: never rely on implicit OGR driver defaults for CRS normalization — they prioritise throughput over projection fidelity and can drop vertical datums or apply heuristic shifts without logging. Enforce an explicit ogr2ogr -t_srs / pyproj.Transformer pipeline for vector archival outputs, and reserve gdalwarp for raster reprojection.

Cost of Getting the Order Wrong

CRS transformation is cheap per feature and expensive per rerun. Because it is a write-time operation, discovering an error after the archive has been written turns a millisecond of CPU into a full restore-and-rewrite cycle across every affected object — and under a retention lock, into a defect that cannot be corrected at all until the window expires.

Cost of a CRS error by the stage at which it is caught Four escalating stages showing the cost of correcting a reference-system error, from four CPU-minutes in staging to an uncorrectable defect once an object lock is in force. in staging re-run the transform 4 CPU-minutes written, still warm rewrite 40 GB ~$1 + a job after cold tiering restore + rewrite + penalty ~$40 + hours after Object Lock no correction possible until retention expires The same one-line error, priced by how late it is found Every gate you can move left of the lifecycle transition pays for itself the first time it fires.

This is the argument for putting the reference-system assertion in the ingest job rather than in a nightly audit. An assertion that runs before the write costs milliseconds and blocks a bad object from ever existing; the same assertion running a week later finds a defect that is now expensive to fix and may already be immutable. Pair it with the drift audit described in Detecting and Fixing CRS Drift in Archived Datasets, which covers the objects that were written before the gate existed.

Operational Execution Checklist

Frequently Asked Questions

Does reprojecting change the file’s compression ratio?

Yes, sometimes substantially. Projected coordinates in metres have a very different digit distribution from geographic degrees, and the codec models them differently — a dataset reprojected from EPSG:4326 to a national grid commonly shifts its geometry-column ratio by ten to twenty percent in either direction. That is a reason to fix the reference system before tuning compression, not after, so the measured ratios describe the bytes the archive will actually hold.

Should archives be stored in geographic or projected coordinates?

Store in the system the data will be analysed in, and record the full definition either way. Geographic coordinates travel better across tools and avoid a projection choice that ages badly; projected coordinates avoid a per-query transformation for area and distance work. What matters more than the choice is uniformity: an archive holding a mix of both, with the difference recorded only in per-file metadata, forces every reader to reproject before it can compare anything.

How should the epoch be handled for a dynamic reference frame?

Store it explicitly and never assume it. In a plate-fixed frame, coordinates drift by centimetres a year, so a position without an epoch is ambiguous at exactly the accuracy that survey-grade archives care about. Record the epoch alongside the CRS identifier in the manifest, and treat a dataset whose epoch is unknown as lower-accuracy data rather than silently adopting the current one.

  • Up one level: CRS Synchronization in Pipelines — the parent design page covering target-CRS selection, quarantine policy, and write-time partition constraints this procedure implements.
  • GeoParquet Migration Workflows — the sibling conversion path where the normalised CRS is embedded into columnar geometry metadata during the archival write.
  • Schema Mapping & Attribute Validation — the attribute contract that runs alongside reprojection so a precision change never breaks a join key.
  • Metadata Cataloging & Discovery — the catalog layer that records the transformation pipeline string and grid version as lineage for every committed artifact.