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
# 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
-- 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
-- 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:
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
# 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.
Step 5: Record the run in the same transaction as the data Jump to heading
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
-- 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
# 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 |
Related Jump to heading
- Incremental Change Detection & Delta Loads — the parent stage and the delta this procedure consumes
- Computing Geometry Hashes for Change Detection — where the content hash the upsert compares comes from
- Zero-Downtime Column Renames in Live Spatial Tables — the dual-write requirement this write path must honour during a migration
- Building a Dead-Letter Queue for Failed Feature Transforms — where features that never reach the apply step go