Automating CRS Reprojection in Airflow

When domains ingest data in whatever CRS their source vendor emits — a cadastral extract in EPSG:32633, a survey feed in a local grid, a partner drop in web mercator — the mesh cannot federate until every product lands in one canonical reference system. Automating CRS reprojection in Airflow builds a scheduled DAG that reprojects each incoming dataset to EPSG:4326 with ogr2ogr and PostGIS ST_Transform, writes to a deterministic idempotent output path, and gates publication behind a data-quality check so a retried run never produces a second, divergent artifact. It is the Airflow implementation of the patterns in Orchestrating Spatial Pipelines in Python, within Spatial Pipeline Orchestration & Observability; the Prefect counterpart is Building Idempotent Spatial DAGs with Prefect.

Prerequisites

Requirement Version / Assumption Notes
Airflow >= 2.7 TaskFlow @task and BashOperator
GDAL / ogr2ogr >= 3.6 Reprojection and ogrinfo verification
PostGIS >= 3.3 on PostgreSQL >= 14 ST_Transform and ST_IsValid checks
CRS assumption Source in any declared CRS; output EPSG:4326 EPSG:3857 reserved for downstream tiling
Env vars AIRFLOW_CONN_DOMAIN_PG, REPROJECT_OUT, SRC_URI Domain-scoped Postgres conn; no cross-domain access
Role reprojection-writer (scoped) Rotated credential; zero-trust to other stores
Output convention {layer}/{src_crs}__4326/part.gpkg Deterministic path keyed on layer and source CRS

Figure — An Airflow DAG: detect source CRS, reproject to EPSG:4326 on a deterministic path, run a data-quality check, then publish only on pass.

Airflow CRS reprojection DAG Four tasks run in sequence. Detect CRS reads the source spatial reference. Reproject transforms the dataset to EPSG:4326 and writes to a deterministic output path derived from the layer and source CRS. A data-quality check validates feature count and geometry validity. On pass, publish writes the catalog entry; on failure, the DAG stops before publication so no invalid artifact is exposed. Detect CRS ogrinfo -so Reproject ogr2ogr → EPSG:4326 Quality check count + ST_IsValid Publish on pass deterministic output path fail → stop before publish

Step-by-Step Implementation

Each step is verifiable before you proceed. The invariant is that the output path is a pure function of the layer and source CRS, so a retried task overwrites the same object rather than creating a new one.

1. Detect the source CRS deterministically

Read the source reference with ogrinfo and pin it into an XCom, so downstream tasks derive the output path from a known value rather than guessing.

python
# dag.py
from airflow.decorators import dag, task
from datetime import datetime
import subprocess, re, os

@dag(schedule="0 3 * * *", start_date=datetime(2026, 1, 1), catchup=False)
def crs_reprojection():

    @task
    def detect_crs(src: str) -> str:
        out = subprocess.check_output(["ogrinfo", "-so", "-al", src], text=True)
        m = re.search(r"ID\[\"EPSG\",(\d+)\]", out)
        return f"EPSG:{m.group(1)}"  # e.g. EPSG:32633

Verify: confirm the detected CRS matches the source.

bash
ogrinfo -so -al "$SRC_URI" | grep -Eo 'EPSG",[0-9]+' | head -1

2. Reproject to EPSG:4326 on a deterministic path

A BashOperator-style task calls ogr2ogr with -t_srs EPSG:4326. The output path is derived from the layer and source CRS, so re-running overwrites rather than appends. The -overwrite flag makes the write idempotent.

python
    @task
    def reproject(src: str, src_crs: str, layer: str) -> str:
        out = f"{os.environ['REPROJECT_OUT']}/{layer}/{src_crs.replace(':','_')}__4326/part.gpkg"
        os.makedirs(os.path.dirname(out), exist_ok=True)
        subprocess.run([
            "ogr2ogr", "-overwrite",
            "-s_srs", src_crs, "-t_srs", "EPSG:4326",
            "-nln", layer, out, src,
        ], check=True)  # domain-scoped credential via GDAL config
        return out

Verify: confirm the output is genuinely in EPSG:4326.

bash
ogrinfo -so -al "$REPROJECT_OUT/parcels/EPSG_32633__4326/part.gpkg" | grep -i '4326'

3. Run the data-quality check in PostGIS

Load the reprojected features and assert feature count and geometry validity with ST_IsValid. This is the gate: it must raise on any invalid geometry so the DAG stops before publication.

python
    @task
    def quality_check(path: str, layer: str) -> str:
        subprocess.run([
            "ogr2ogr", "-f", "PostgreSQL",
            os.environ["AIRFLOW_CONN_DOMAIN_PG"], path, "-nln", f"stg_{layer}",
        ], check=True)
        sql = f"SELECT count(*) FILTER (WHERE NOT ST_IsValid(geom)) FROM stg_{layer};"
        invalid = int(run_psql_scalar(sql))
        if invalid:
            raise ValueError(f"quality gate: {invalid} invalid geometries in {layer}")
        return path

Verify: count invalid geometries directly.

bash
psql "$AIRFLOW_CONN_DOMAIN_PG" -tAc \
  "SELECT count(*) FILTER (WHERE NOT ST_IsValid(geom)) FROM stg_parcels;"

4. Publish only on a passing check and wire the dependency

Publication runs strictly after the quality gate, so a failed check leaves the previous product version untouched.

python
    @task
    def publish(path: str, layer: str, version: str):
        write_catalog_entry(layer, version=version, artifact=path, crs="EPSG:4326")

    src = os.environ["SRC_URI"]
    crs = detect_crs(src)
    reproj = reproject(src, crs, "parcels")
    checked = quality_check(reproj, "parcels")
    publish(checked, "parcels", "v1.2.0-crs:EPSG:4326-res:10m")

crs_reprojection()

Verify: trigger the DAG and confirm task ordering and success.

bash
airflow dags test crs_reprojection 2026-07-14

5. Confirm idempotency across a re-run

Re-running over unchanged input must produce a byte-stable output path and no duplicate objects.

bash
airflow tasks test crs_reprojection reproject 2026-07-14
ls "$REPROJECT_OUT/parcels/EPSG_32633__4326/" | wc -l   # stays 1 across runs

Configuration Reference

Parameter Required Value Effect
-t_srs Yes EPSG:4326 Canonical target CRS for every dataset
-s_srs Yes detected source CRS Explicit source avoids GDAL misdetection
-overwrite Yes set Makes the reproject write idempotent
Output path Yes {layer}/{src_crs}__4326/part.gpkg Deterministic, keyed on layer and source CRS
schedule Yes 0 3 * * * Nightly cadence; catchup=False
Quality gate Yes ST_IsValid count = 0 Blocks publish on invalid geometry
AIRFLOW_CONN_DOMAIN_PG Yes scoped DSN Domain-only Postgres; zero-trust

Common Failure Modes & Fixes

ogr2ogr reprojects but coordinates look wrong. Root cause: the source CRS was misdetected or omitted, so GDAL assumed the wrong -s_srs. Fix: pass the detected -s_srs explicitly from step 1 and re-verify with ogrinfo -so -al ... | grep 4326 plus a spot-check of a known coordinate.

A retry creates a second output file. Root cause: the output path included a timestamp or run id, so each attempt wrote a new object. Fix: derive the path only from layer and src_crs, add -overwrite, and confirm the directory holds exactly one file across runs.

Quality check passes but downstream sees invalid polygons. Root cause: the gate queried the wrong staging table or ran before the load completed. Fix: make quality_check depend on the load, target stg_{layer}, and assert the invalid count is zero with psql.

Axis order flips (lat/lon swapped). Root cause: a datum or authority-compliant axis-order difference between source and EPSG:4326. Fix: add -t_srs EPSG:4326 with GDAL OGR_CT_FORCE_TRADITIONAL_GIS_ORDER=YES when the source uses authority order, then re-verify a sample coordinate.

DAG reprojects the whole history every night. Root cause: no change detection, so unchanged sources are reprocessed. Fix: gate the reproject on a source content hash or modification time, skipping the task when the input is unchanged.

FAQ

Why standardize on EPSG:4326 rather than keeping native CRS?

A federated mesh joins products across domains, and a spatial join across mixed CRS silently corrupts results. Reprojecting every ingest to EPSG:4326 gives one canonical interchange CRS so any two products are directly comparable, while web-mercator EPSG:3857 stays reserved for the tiling stage. Native survey CRS such as EPSG:32633 remain valid inside a single survey-grade domain, but the published, cross-domain artifact is always EPSG:4326.

How does the deterministic output path guarantee idempotency?

Because the path is a pure function of the layer and source CRS — {layer}/{src_crs}__4326/part.gpkg — every run for the same input writes to the same object, and -overwrite replaces it in place. A retried task therefore cannot create a divergent second artifact; it reproduces the same file. This is the Airflow analogue of Prefect’s cache_key_fn, anchoring idempotency on the path rather than a cache store.

Should reprojection run in ogr2ogr or PostGIS ST_Transform?

Use ogr2ogr for file-to-file batch reprojection at ingest — it is fast, streams large datasets, and needs no database round-trip. Use PostGIS ST_Transform when the data already lives in Postgres and you are reprojecting inside a query, or when the quality check must run in SQL anyway. This DAG uses ogr2ogr for the bulk transform and PostGIS only for the ST_IsValid gate.

What makes the quality check a hard gate rather than a warning?

The quality_check task raises on any invalid geometry, and Airflow marks it failed, which stops the DAG before publish runs. Because publication is the only task that writes the catalog entry, a failed gate leaves the prior product version in place and never exposes the invalid artifact. Keeping publish strictly downstream of the gate is what turns validation into an enforced boundary.

Can I reproject to a projected CRS for a specific domain?

Yes — a survey-grade domain can keep an internal artifact in EPSG:32633 for precision-sensitive work. But the cross-domain published product must still be offered in EPSG:4326, so add a second reproject branch that emits the canonical version alongside the projected one, each on its own deterministic path.