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:
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 ogr2ogr — gdalwarp 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.
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.
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.
Related
- 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.