Making PostGIS Upserts Idempotent for Feature Loads

A feature load is idempotent when running it twice leaves the table exactly as running it once did. In PostGIS that means three things together: a unique constraint on a stable natural key, an INSERT … ON CONFLICT … DO UPDATE that targets that constraint, and a WHERE clause on the update that suppresses no-op writes. The third part is the one usually skipped, and it is what stops a nightly re-load from rewriting four million unchanged rows, bloating the table and firing every trigger and replication event as though the data had changed.

When to Use This Pattern

  • The source publishes a stable feature identifier — a cadastral number, a gml_id, an INSPIRE local id — that survives between deliveries.
  • Deliveries overlap: today’s extract contains most of yesterday’s features, so the load is a merge rather than an append.
  • You want row-level rather than delivery-level safety. Where the whole delivery is the unit, an idempotency key for the shapefile bundle is cheaper and simpler.
  • Downstream consumers watch updated_at or listen to logical replication, so spurious updates are not merely wasteful but actively misleading.

The pattern does not apply when the source has no stable identifier — an extract keyed on a row number that renumbers every publication. There the honest answer is a full replace into a staging table followed by an atomic swap, not an upsert pretending to be one.

What the second run costs under three strategiesPlain insert adds four hundred thousand duplicate rows. Unconditional upsert rewrites four hundred thousand unchanged rows. The guarded upsert writes only the twelve hundred rows that genuinely changed.rows written by the SECOND run of an identical deliveryplain INSERT400 000 duplicates — the table is now wrongON CONFLICT DO UPDATE400 000 rewrites — correct, but all bloat…DO UPDATE WHERE changed1 200only the rows that really movedAll three are “idempotent” in the sense that the final state matches; only the third is idempotent in cost and in side effects.
The middle row is the trap: the query is correct, the table ends up right, and the database still does four hundred thousand row versions of work every night.

Complete Working Example

The load below stages a batch, then merges it in one statement. The conflict target is a unique index on the natural key; the update guard compares the geometry with ST_OrderingEquals and the attributes with IS DISTINCT FROM, so identical rows are left alone.

from __future__ import annotations

from typing import Iterable, Sequence

import psycopg
from psycopg import sql

MERGE_SQL = """
INSERT INTO parcels AS p (parcel_id, geom, owner_name, area_m2, source_batch, updated_at)
SELECT s.parcel_id,
       ST_MakeValid(ST_Transform(s.geom, 25832)) AS geom,
       s.owner_name,
       s.area_m2,
       %(batch)s,
       now()
FROM   parcels_stage s
ON CONFLICT (parcel_id) DO UPDATE
   SET geom         = EXCLUDED.geom,
       owner_name   = EXCLUDED.owner_name,
       area_m2      = EXCLUDED.area_m2,
       source_batch = EXCLUDED.source_batch,
       updated_at   = now()
 WHERE p.owner_name IS DISTINCT FROM EXCLUDED.owner_name
    OR p.area_m2    IS DISTINCT FROM EXCLUDED.area_m2
    -- Geometry needs an explicit comparison: the = operator on geometry compares
    -- bounding boxes, so two different shapes with the same envelope look equal.
    OR NOT ST_OrderingEquals(p.geom, EXCLUDED.geom)
"""


def merge_batch(conn: psycopg.Connection, rows: Iterable[Sequence], batch: str) -> int:
    """Stage a delivery and merge it. Returns the number of rows actually written."""
    with conn.transaction():
        with conn.cursor() as cur:
            # An UNLOGGED staging table skips WAL for data we are about to discard.
            cur.execute(
                "CREATE UNLOGGED TABLE IF NOT EXISTS parcels_stage "
                "(parcel_id text, geom geometry(MultiPolygon, 4326), "
                " owner_name text, area_m2 double precision)"
            )
            cur.execute("TRUNCATE parcels_stage")

            with cur.copy(
                "COPY parcels_stage (parcel_id, geom, owner_name, area_m2) FROM STDIN"
            ) as copy:
                for row in rows:
                    copy.write_row(row)

            # Deduplicate WITHIN the delivery first: ON CONFLICT cannot handle a
            # conflict target hit twice by the same statement.
            cur.execute(
                "DELETE FROM parcels_stage a USING parcels_stage b "
                "WHERE a.ctid < b.ctid AND a.parcel_id = b.parcel_id"
            )

            cur.execute(MERGE_SQL, {"batch": batch})
            return cur.rowcount

The schema this depends on is unremarkable except for two constraints that carry all the weight:

CREATE TABLE parcels (
    parcel_id    text PRIMARY KEY,                       -- the natural key, not a serial
    geom         geometry(MultiPolygon, 25832) NOT NULL,
    owner_name   text,
    area_m2      double precision,
    source_batch text        NOT NULL,
    updated_at   timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT parcels_geom_valid CHECK (ST_IsValid(geom))
);

CREATE INDEX parcels_geom_gist ON parcels USING gist (geom);

parcel_id is the primary key rather than a surrogate bigserial, because a surrogate key generated at load time is different on every run and gives ON CONFLICT nothing to match. The CHECK (ST_IsValid(geom)) is the second load-bearing line: it converts “we merged a self-intersecting polygon and discovered it three joins later” into a failure at the moment of writing, where the batch id is still in scope.

The four populations in an overlapping deliveryMost features are present in both and unchanged. A small band is present in both but changed. A small set is new to today's delivery, and another set is present only in yesterday's table and has been withdrawn.yesterday’s tabletoday’s deliverywithdrawnsoft-delete by batch398 800 unchangedthe guard skips these1 200 changed → UPDATEnewINSERTThe upsert handles the middle and the right. Only the left population needs a separate decision.
Sizing these four populations from a real delivery is the fastest way to tell whether an upsert is the right shape at all — if “unchanged” is small, you are doing a replace, not a merge.

Parameter & Option Reference

Parameter Type Default Spatial notes
conflict target unique index parcel_id Must be a real unique constraint. ON CONFLICT cannot use a partial index unless the predicate is repeated in the statement.
geometry comparison function ST_OrderingEquals Compares vertex order and values. = compares bounding boxes only; ST_Equals is spatially correct but far slower and treats re-ordered rings as equal.
ST_MakeValid function applied Cheap insurance against a source that ships self-intersections. Pair it with the CHECK so an unfixable geometry fails loudly.
target SRID int 25832 Transform once, at the boundary. Storing mixed SRIDs in one column is legal in PostGIS and ruins every subsequent index scan.
staging table UNLOGGED yes Skips WAL for throwaway data. It is lost on a crash, which is exactly right for a table that is truncated at the start of every load.
batch size rows 50 000–500 000 One transaction per delivery is simplest; split only when the delivery is large enough that a long transaction blocks autovacuum.

Verification & Testing

The property to assert is that the second identical run writes nothing. cur.rowcount from the merge is the direct measurement.

def test_second_identical_load_is_a_no_op(conn, delivery) -> None:
    first = merge_batch(conn, delivery.rows(), batch="2026-08-07a")
    assert first == len(delivery.rows())

    second = merge_batch(conn, delivery.rows(), batch="2026-08-07b")
    assert second == 0, "an unchanged re-load must not write any rows"

    # And a genuine change must still get through:
    changed = delivery.with_owner("12345", "New Owner AS")
    third = merge_batch(conn, changed.rows(), batch="2026-08-07c")
    assert third == 1

In production the equivalent check is a query, run right after the load, that compares the batch just written against the row count that changed:

-- A re-delivery that touched thousands of rows means either the source really
-- changed, or the guard is not catching a column that is being rewritten
-- gratuitously (a recomputed area, a re-serialised geometry).
SELECT source_batch, count(*) AS rows_touched, min(updated_at), max(updated_at)
FROM   parcels
WHERE  updated_at > now() - interval '1 hour'
GROUP  BY source_batch
ORDER  BY 1;
What happens to one incoming featureAn incoming feature either finds no matching key and is inserted, finds a match with identical geometry and attributes and is skipped, or finds a match that differs and is updated.staged featureparcel_id = 12345key already present?unique index lookupno — INSERTone new row versionyes — compareST_OrderingEquals + IS DISTINCT FROMequal → skip · differs → UPDATE
The comparison is not a nicety. Without it the “yes” branch always writes, and an unchanged nightly delivery costs a full table rewrite.

Common Pitfalls

  • Using = to compare geometries. In PostGIS the = operator on the geometry type compares bounding boxes, not shapes. Two entirely different polygons sharing an envelope compare equal, so the guard silently suppresses real updates. ST_OrderingEquals is the fast, exact comparison; reserve ST_Equals for the rare case where vertex order genuinely does not matter.
  • A conflict target that is not unique. ON CONFLICT (parcel_id) requires a unique index on parcel_id. Without one PostgreSQL raises there is no unique or exclusion constraint matching the ON CONFLICT specification — an error that only appears once the table is big enough that someone dropped the index to speed up a load.
  • Duplicates inside one delivery. ON CONFLICT cannot resolve the same key twice in a single statement; it raises ON CONFLICT DO UPDATE command cannot affect row a second time. Deduplicate in staging first, as above, and decide deliberately which duplicate wins.
  • Forgetting the SRID transform. Merging WGS84 geometry into a 25832 column raises an error if the column is typed, and silently stores mixed SRIDs if it is not. Transform at the boundary and keep CRS validation ahead of the load.
  • Letting updated_at change on every run. If updated_at sits outside the guard — set unconditionally by a trigger, for instance — then every row is “changed” and the WHERE clause is decorative. Any trigger that touches the row must respect the same condition.

Frequently Asked Questions

Is an upsert enough, or do I still need an idempotency key?

For the rows, an upsert is enough and finer-grained. What it cannot cover is the rest of the delivery’s effects — a refreshed materialised view, a published tile set, a notification. Those are still one-shot side effects, and they still want a ledger entry keyed on the delivery, as described in idempotency keys in spatial ETL.

How do I handle features that disappear from the source?

An upsert never deletes. Tag every merged row with the source_batch, then, in the same transaction, soft-delete the rows whose batch is older than the current one within the delivery’s extent. Doing it by extent rather than globally is what keeps a regional delivery from retiring the whole country.

Does `ST_OrderingEquals` cost much on large polygons?

It is a vertex-by-vertex comparison, so it costs roughly what reading the geometry costs — and it only runs for keys that already matched. On a 400 000-row merge with a few thousand real changes it is far cheaper than the row rewrites it avoids. If your geometries are enormous, compare a stored hash of the WKB instead and let that be the guard.

What about concurrent loads of overlapping deliveries?

ON CONFLICT is atomic per row, so concurrent merges will not duplicate. They can still interleave such that an older delivery overwrites a newer one. If deliveries can arrive out of order, add AND EXCLUDED.published_at > p.published_at to the guard so the newer publication always wins.

Idempotency Keys in Spatial ETL