Applying Delta Loads to a PostGIS Target Idempotently Jump to heading

Detecting change is the interesting half; applying it is the half that has to be correct at three in the morning. An apply step that is not idempotent turns every retry into a decision — did the first attempt get as far as the deletes? — and every failed run into an incident that needs a human to reason about partial state. This procedure makes re-applying the same delta a no-op, so a failed run is retried rather than investigated. It implements the write half of incremental change detection and delta loads, inside Automated Attribute Transformation & ETL Workflows.

The steps map to configure (Step 1, staging), execute (Steps 2–3, upsert and soft delete), validate (Step 4, the circuit breaker), and log (Step 5, the ledger). The design constraint throughout is that the target is read by live services, so no step may hold a long lock or leave the table in a half-updated state a map request could observe.

Prerequisites checklist Jump to heading

Step 1: Stage the batch in one COPY Jump to heading

python
# etl/apply.py — psycopg >=3.1 — Python 3.10+
import psycopg

STAGE_DDL = """
CREATE UNLOGGED TABLE IF NOT EXISTS parcels_stage (
    stable_id      text PRIMARY KEY,
    geom           geometry(Polygon, 25832) NOT NULL,
    land_use_code  text NOT NULL,
    ownership_status text,
    content_hash   text NOT NULL
);
TRUNCATE parcels_stage;
"""


def stage(conn: psycopg.Connection, rows) -> int:
    with conn.cursor() as cur:
        cur.execute(STAGE_DDL)
        with cur.copy(
            "COPY parcels_stage (stable_id, geom, land_use_code, ownership_status, content_hash) "
            "FROM STDIN (FORMAT BINARY)"
        ) as copy:
            for row in rows:
                copy.write_row(row)
        cur.execute("ANALYZE parcels_stage")     # the planner needs stats for the join below
        return cur.rowcount

An unlogged staging table is the right shape here: it skips WAL for the load, it is truncated at the start of every run so no state survives between runs, and losing it on a crash costs nothing because the delta is re-derivable. ANALYZE after the load matters more than it looks — without statistics the planner will choose a nested loop against the target for the upsert join, which turns a two-minute apply into an hour.

Step 2: Upsert on the stable identifier Jump to heading

sql
-- PostgreSQL >= 16, PostGIS >= 3.4 — the whole insert-or-update, in one statement.
INSERT INTO parcels AS target
       (stable_id, geom, land_use_code, ownership_status, content_hash, updated_at, retired_at)
SELECT s.stable_id, s.geom, s.land_use_code, s.ownership_status, s.content_hash, now(), NULL
  FROM parcels_stage s
    ON CONFLICT (stable_id) DO UPDATE
   SET geom             = EXCLUDED.geom,
       land_use_code    = EXCLUDED.land_use_code,
       ownership_status = EXCLUDED.ownership_status,
       content_hash     = EXCLUDED.content_hash,
       updated_at       = now(),
       retired_at       = NULL                     -- resurrect a feature that came back
 WHERE target.content_hash IS DISTINCT FROM EXCLUDED.content_hash
    OR target.retired_at IS NOT NULL;

The WHERE clause on the DO UPDATE is what makes the statement idempotent in the way that matters. Without it, re-applying the same delta rewrites every row: updated_at moves, the row version changes, replication ships the whole table again, and the audit trail records a change that did not happen. With it, a second application of an identical delta updates zero rows and the statement is a cheap no-op.

retired_at = NULL handles the resurrection case, which real data produces more often than expected — a parcel deregistered in error and reinstated the following week must return as the same feature, not as a new one.

Step 3: Soft-delete what is absent Jump to heading

sql
-- Features present in the target but not in this delivery.
UPDATE parcels t
   SET retired_at = now()
 WHERE t.retired_at IS NULL
   AND NOT EXISTS (SELECT 1 FROM parcels_stage s WHERE s.stable_id = t.stable_id);

Soft deletion is not squeamishness. A hard delete applied to a truncated extract removes data that the source still holds and that no one has a copy of; recovering it means a restore, with everything that implies for a live system. A soft delete is reversible with an UPDATE, and it keeps the identifier resolvable for downstream systems that still hold it — the same argument that governs absorbed identifiers in duplicate detection.

Every reader must then filter retired_at IS NULL. Enforce that with a view rather than with discipline:

sql
CREATE OR REPLACE VIEW parcels_current AS
SELECT * FROM parcels WHERE retired_at IS NULL;

Step 4: Guard against a delete storm before committing Jump to heading

python
# etl/apply.py — psycopg >=3.1 — Python 3.10+
DELETE_CEILING = 0.05        # 5 % of live features in one run


def apply_delta(conn: psycopg.Connection, rows, policy: dict) -> dict:
    with conn.transaction():                 # everything below commits or none of it does
        with conn.cursor() as cur:
            cur.execute("SET LOCAL lock_timeout = '2s'")
            cur.execute("SET LOCAL statement_timeout = '120s'")

            staged = stage(conn, rows)
            cur.execute("SELECT count(*) FROM parcels WHERE retired_at IS NULL")
            (live_before,) = cur.fetchone()

            cur.execute(UPSERT_SQL)
            upserted = cur.rowcount

            cur.execute(SOFT_DELETE_SQL)
            retired = cur.rowcount

            if live_before and retired / live_before > policy.get("delete_ceiling", DELETE_CEILING):
                raise DeleteStorm(
                    f"{retired} of {live_before} features would be retired "
                    f"({retired / live_before:.1%}) — above the ceiling; aborting"
                )

            cur.execute(LEDGER_INSERT, {"staged": staged, "upserted": upserted,
                                        "retired": retired, "run_id": policy["run_id"]})
    return {"staged": staged, "upserted": upserted, "retired": retired}

The circuit breaker lives inside the transaction, which is the whole point: raising rolls back the upserts and the soft deletes together, leaving the target exactly as it was. A breaker that runs after the commit is a report, not a guard.

A truncated extract is the common cause, and it is worth naming the second one: a source that changes its identifier scheme makes every existing feature look absent and every incoming feature look new. The delete count and the insert count are then both enormous, which the ceiling catches from the delete side before the target is rewritten.

One Transaction, Five Steps, Two Possible Endings A single transaction boundary encloses five sequential steps: copy into the unlogged staging table, upsert into the target keyed on the stable identifier, soft-delete features absent from the delivery, evaluate the delete-storm ceiling, and insert the run ledger row. Two exits leave the transaction: a commit, which makes the data change and its ledger row visible together, and an abort triggered by the ceiling, which rolls back every step so the target is byte-identical to its state before the run. A note records that because the upsert skips rows whose content hash is unchanged, re-applying the same delta updates zero rows. BEGIN 1 · COPY into staging 2 · upsert on stable_id 3 · soft-delete absent 4 · delete-storm ceiling retired ÷ live_before 5 · ledger row same transaction as the data COMMIT data and ledger appear together ROLLBACK target unchanged; retry is safe ceiling applying the same delta twice updates zero rows: the upsert skips unchanged content hashes

Step 5: Record the run in the same transaction as the data Jump to heading

sql
CREATE TABLE IF NOT EXISTS delta_run_ledger (
    run_id        text PRIMARY KEY,
    layer         text NOT NULL,
    policy_version text NOT NULL,
    staged        bigint NOT NULL,
    upserted      bigint NOT NULL,
    retired       bigint NOT NULL,
    applied_at    timestamptz NOT NULL DEFAULT now()
);

Writing the ledger row inside the same transaction as the data is what makes the two impossible to disagree about. A ledger written afterwards, from application code, is written only when the process survives the commit — and the runs it misses are exactly the ones anyone would want to know about. Making run_id the primary key adds a second guarantee: a retried run that already committed fails on the ledger insert rather than applying its data twice.

Verification Jump to heading

sql
-- Re-apply the identical delta and confirm nothing moved.
SELECT upserted, retired FROM delta_run_ledger ORDER BY applied_at DESC LIMIT 2;
--  upserted | retired
-- ----------+---------
--         0 |       0      ← second application
--      1918 |     142      ← first application
python
# tests/test_apply.py — pytest >=7
def test_apply_twice_is_a_no_op(target_db, delta):
    first = apply_delta(target_db, delta, POLICY | {"run_id": "r1"})
    snapshot = dump_table(target_db, "parcels")
    second = apply_delta(target_db, delta, POLICY | {"run_id": "r2"})
    assert second["upserted"] == 0 and second["retired"] == 0
    assert dump_table(target_db, "parcels") == snapshot


def test_delete_storm_rolls_everything_back(target_db, truncated_delta):
    before = dump_table(target_db, "parcels")
    with pytest.raises(DeleteStorm):
        apply_delta(target_db, truncated_delta, POLICY | {"run_id": "r3"})
    assert dump_table(target_db, "parcels") == before      # including the upserts


def test_resurrected_feature_keeps_its_identifier(target_db, delta_with_return):
    apply_delta(target_db, delta_with_return, POLICY | {"run_id": "r4"})
    row = fetch(target_db, "PARCEL-14-023-9")
    assert row["retired_at"] is None

Troubleshooting Jump to heading

Symptom Likely cause Fix
ON CONFLICT raises “no unique or exclusion constraint matching” No unique constraint on stable_id Add one; without it an upsert cannot exist and inserts silently duplicate
Second application updates every row The WHERE ... IS DISTINCT FROM guard is missing from DO UPDATE Add it; otherwise idempotence is only skin-deep
Apply is slow and the plan shows a nested loop Staging table has no statistics ANALYZE the staging table after the COPY
A run is recorded but the data is missing Ledger written outside the transaction Move the ledger insert inside; commit them together
Deletes accumulate but space never returns Soft deletes never purged Purge retired rows past the retention window as a separate, approved job
Retried run applies the delta twice Ledger run_id not a primary key Make it one; the duplicate insert then aborts the retry
Readers still see retired features Some queries hit the table rather than the view Point readers at parcels_current and revoke direct select where practical