Database-Native Versioning in PostGIS and GeoPackage

Some spatial data is edited continuously by many people at once, and for that pattern the version history belongs where the transactions are — inside the database — rather than in a repository that would need a commit per edit. This guide is part of Choosing Formats & Tools for Spatial Versioning.

Prerequisites & Environment Setup

Before adding a database-native history to a production layer, confirm each of the following:

Core Algorithmic Patterns

1. History as an append-only mirror

The history table mirrors the live table’s columns and adds four of its own: what operation occurred, when, by whom, and the transaction that did it. Every write to the live table appends the previous state to history, so the live table always holds the current row and history holds every row it replaced.

Appending the previous state rather than the new one is a deliberate choice. It means the live table and the history together contain each state exactly once, with no duplication of the current row — and a restore to time t reads history for rows superseded after t and the live table for everything else.

2. Attribution flows through the session, not the row

A history row that records postgres as the author is worthless. Where every editor has a database role, current_user is sufficient. Where the application pools connections under one role — which is most web editing stacks — the application must set the acting user on the session:

SET LOCAL app.user_id  = 'a.okonkwo';
SET LOCAL app.reason   = 'boundary correction, ticket GIS-4471';

SET LOCAL scopes the setting to the transaction, so a pooled connection cannot leak one editor’s identity into another’s writes. A trigger that reads a session variable set with plain SET will eventually attribute an edit to whoever used the connection previously.

3. Triggers capture rows; they do not capture intent

A trigger sees UPDATE parcels SET geom = … and records it faithfully. What it cannot see is that this update and the next four were one operation — a parcel split — performed by one person for one reason.

Recording a transaction identifier on every history row recovers that grouping: rows sharing a transaction id were one logical operation. Combined with the session reason, a reviewer sees “five rows changed in one transaction: parcel split, ticket GIS-4471” rather than five unrelated edits.

Two histories, one link between them Three bands. The database band holds the live table and its append-only history, the export band holds the deterministic snapshot and its manifest, and the repository band holds the commit that published it — with the export log recording the transaction and commit together. DATABASE live table history table EXPORT deterministic snapshot manifest REPOSITORY commit export log row snapshotted at a txid recorded together Without the bottom band the two histories drift, and no question spanning both can be answered.

Production Workflow Implementation

Step 1 — Create the history table

CREATE TABLE parcels_history (
    history_id   bigserial PRIMARY KEY,
    -- mirror of the live table
    parcel_id    uuid        NOT NULL,
    geom         geometry(MultiPolygon, 3035),
    owner_ref    text,
    land_use     text,
    -- audit columns
    operation    char(1)     NOT NULL CHECK (operation IN ('I', 'U', 'D')),
    changed_at   timestamptz NOT NULL DEFAULT now(),
    changed_by   text        NOT NULL,
    change_reason text,
    txid         bigint      NOT NULL
);

CREATE INDEX parcels_history_entity_idx ON parcels_history (parcel_id, changed_at DESC);
CREATE INDEX parcels_history_txid_idx   ON parcels_history (txid);
CREATE INDEX parcels_history_geom_gix   ON parcels_history USING gist (geom);

The (parcel_id, changed_at DESC) index is the one that makes as-of reads viable; without it every historical question scans the whole history table.

Step 2 — Install the trigger

CREATE OR REPLACE FUNCTION parcels_audit() RETURNS trigger AS $$
DECLARE
    acting_user text := coalesce(
        current_setting('app.user_id', true),   -- true = do not error if unset
        current_user
    );
    reason text := current_setting('app.reason', true);
BEGIN
    IF TG_OP = 'DELETE' THEN
        INSERT INTO parcels_history
            (parcel_id, geom, owner_ref, land_use, operation,
             changed_by, change_reason, txid)
        VALUES (OLD.parcel_id, OLD.geom, OLD.owner_ref, OLD.land_use, 'D',
                acting_user, reason, txid_current());
        RETURN OLD;
    END IF;

    IF TG_OP = 'UPDATE' THEN
        -- Skip no-op updates: an application that rewrites unchanged rows
        -- would otherwise fill history with entries that record nothing.
        IF OLD IS NOT DISTINCT FROM NEW THEN
            RETURN NEW;
        END IF;
        INSERT INTO parcels_history
            (parcel_id, geom, owner_ref, land_use, operation,
             changed_by, change_reason, txid)
        VALUES (OLD.parcel_id, OLD.geom, OLD.owner_ref, OLD.land_use, 'U',
                acting_user, reason, txid_current());
        RETURN NEW;
    END IF;

    INSERT INTO parcels_history
        (parcel_id, geom, owner_ref, land_use, operation,
         changed_by, change_reason, txid)
    VALUES (NEW.parcel_id, NULL, NULL, NULL, 'I',
            acting_user, reason, txid_current());
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER parcels_audit_trg
    AFTER INSERT OR UPDATE OR DELETE ON parcels
    FOR EACH ROW EXECUTE FUNCTION parcels_audit();

The no-op check is worth its four lines. Editing clients frequently rewrite every attribute on save whether or not anything changed, and without the check a day of browsing produces a day of history.

Step 3 — Restore a table to a past state

CREATE OR REPLACE FUNCTION parcels_as_of(at timestamptz)
RETURNS TABLE (parcel_id uuid, geom geometry, owner_ref text, land_use text) AS $$
    -- The most recent history row superseded after `at` holds the state as of `at`
    WITH superseded AS (
        SELECT DISTINCT ON (h.parcel_id)
               h.parcel_id, h.geom, h.owner_ref, h.land_use, h.operation
          FROM parcels_history h
         WHERE h.changed_at > at
         ORDER BY h.parcel_id, h.changed_at ASC
    )
    SELECT s.parcel_id, s.geom, s.owner_ref, s.land_use
      FROM superseded s
     WHERE s.operation <> 'I'                    -- created after `at`: did not exist
    UNION ALL
    SELECT p.parcel_id, p.geom, p.owner_ref, p.land_use
      FROM parcels p
     WHERE NOT EXISTS (
             SELECT 1 FROM parcels_history h
              WHERE h.parcel_id = p.parcel_id AND h.changed_at > at
           );
$$ LANGUAGE sql STABLE;

Reading this function is the fastest way to check that a history design is sound. If restoring a past state requires anything beyond history plus the live table, something is not being recorded.

Step 4 — Do the same inside a GeoPackage

SQLite has no session variables, so attribution comes from a column the application sets:

CREATE TABLE parcels_history (
    history_id   INTEGER PRIMARY KEY AUTOINCREMENT,
    parcel_id    TEXT NOT NULL,
    geom         BLOB,
    owner_ref    TEXT,
    operation    TEXT NOT NULL CHECK (operation IN ('I','U','D')),
    changed_at   TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
    changed_by   TEXT NOT NULL
);

CREATE TRIGGER parcels_audit_update
AFTER UPDATE ON parcels
FOR EACH ROW
BEGIN
    INSERT INTO parcels_history
        (parcel_id, geom, owner_ref, operation, changed_by)
    VALUES (OLD.parcel_id, OLD.geom, OLD.owner_ref, 'U',
            coalesce(NEW.last_edited_by, 'unknown'));
END;

Register the history table in gpkg_extensions so other tools understand it is part of the container rather than stray data, and remember that a container copied to a field device carries its history with it — which is either exactly what you want or a data-minimisation problem, depending on the layer.

Step 5 — Reconcile with the file repository

# Deterministic export, recorded against both histories
TXID=$(psql -Atc "SELECT txid_current_snapshot()::text")
ogr2ogr -f GPKG exports/parcels_$(date -u +%Y%m%dT%H%M%SZ).gpkg \
        PG:"$PGCONN" -sql "SELECT * FROM parcels ORDER BY parcel_id" \
        -lco SPATIAL_INDEX=YES

git add exports/ && git commit -m "Snapshot at txid ${TXID}"
psql -c "INSERT INTO export_log (txid, commit_sha) VALUES ('${TXID}', '$(git rev-parse HEAD)')"

The export_log table is small and does the whole job: given a commit you can find the database state it came from, and given a database transaction you can find the commit that published it.

Which side of the boundary a layer belongs on A decision ladder over an editing pattern: continuous concurrent editing needs the database, batch pipeline output belongs in the repository, and a layer edited in the field belongs in a container synchronised back — with most organisations running all three. Is the layer edited concurrently, all day, through a client? Database-native history triggers, and a snapshot for publication yes no Is it produced by a batch pipeline? File-based versioning pointers and pipeline stages yes no Is it edited in the field, disconnected? A container, synchronised back reconciled on return yes no Repository, with a periodic snapshot the simplest thing that works These are not competing answers — a mature organisation has layers in every rung.

Code Reliability Patterns

Make the history table append-only in practice, not just by convention. Revoke UPDATE and DELETE on it from every role except a dedicated archival role. An audit trail that ordinary application code can modify is not an audit trail.

Handle schema changes on both tables together. A column added to the live table and not to history means history stops recording it silently. Put both ALTER TABLE statements in the same migration, and add a test that compares the two column lists.

Disable the trigger for bulk loads deliberately. Wrap the load in ALTER TABLE parcels DISABLE TRIGGER parcels_audit_trg, then record one history row describing the load. Leaving the trigger on produces millions of rows that record an import nobody will ever query row by row.

Never let a trigger failure be silent. A trigger that swallows an exception to avoid blocking an edit produces gaps in the audit trail exactly where something unusual happened. If history cannot be written, the edit should fail.

Performance & Scale Considerations

History tables grow faster than the layers they track, because they accumulate rather than replace. A parcel layer of 200,000 rows edited at a few hundred changes a day reaches tens of millions of history rows within a few years, dominated by geometry.

Three measures keep that manageable. Partition history by month on changed_at: as-of queries touch the partitions after the target date, and archival becomes a partition detach rather than a mass delete. Move geometry out of unchanged history rows — an attribute-only edit does not need a second copy of the geometry, and a null geometry with a reference to the previous history row halves the table on attribute-heavy layers. Compress and detach old partitions to cheaper storage on the retention schedule you decided before installing the trigger.

The read side is usually fine if the entity index exists. Where it degrades is the whole-layer as-of query on a large history: that is a table scan of every partition after the date, and it is worth materialising as a periodic snapshot instead — which is exactly what the file repository export already is.

What a history table is actually made of Horizontal bars breaking down the storage of a mature history table by contributor: geometry copies dominate, followed by bulk-load noise from imports that ran with the trigger enabled, then attribute snapshots and audit columns. HISTORY TABLE STORAGE BY CONTRIBUTOR Geometry copies 184 GB an attribute edit still copies the geometry Bulk-load noise 96 GB imports that ran with the trigger on Attribute snapshots 21 GB Audit columns 4 GB The top two bars are both avoidable, and together they are 92% of the table.

Troubleshooting & Failure Modes

Symptom Root Cause Fix
Every history row attributed to the pool user Application not setting the session variable Set app.user_id with SET LOCAL on every transaction; make the trigger fail when it is absent in production
History grew by millions of rows overnight A bulk load ran with the trigger enabled Disable the trigger around bulk operations and log one summary row; purge the noise rows by txid
An editor’s identity appears on someone else’s edit Session variable set with SET rather than SET LOCAL on a pooled connection Switch to SET LOCAL; audit the affected window by transaction id
A column silently stopped being recorded Live table altered without altering history Add both tables to one migration; add a test comparing column lists
As-of query takes minutes Missing (entity, changed_at) index, or unpartitioned history Add the composite index; partition by month if the table exceeds tens of millions of rows
Database history and repository disagree Exports taken without recording the transaction Record txid and commit hash together in an export log at every snapshot

FAQ

When is database-native versioning the right choice?

When editing is continuous, concurrent and transactional — many editors touching a live layer through a desktop or web client all day. A file-based repository handles that pattern badly, because every edit would need a commit and every commit rewrites a container. Where the work is batch processing of derived datasets, file-based versioning fits better. Most organisations run both, with the database as the editing surface and the repository as the publication record.

Do triggers slow down editing noticeably?

A row-level trigger writing one history row roughly doubles the cost of a single-row update, which nobody notices interactively. Bulk operations are where it matters: a million-row import writes a million history rows and takes considerably longer. Disable the trigger for bulk work and record the load as a single event.

Can GeoPackage track changes the same way?

Yes, through SQLite triggers writing into a change table in the same container, registered in gpkg_extensions so other tools recognise it. What GeoPackage cannot offer is concurrent multi-writer safety, since SQLite serialises writers — so it suits a single editor or a field container. That is a good fit for offline field collection and a poor one for a shared editing environment.

How does the database history relate to the Git history?

They record different things and have to be linked deliberately. The database records every row change with a transaction timestamp; the repository records reviewed states of an exported artifact. Recording the commit hash against each export transaction lets you answer questions that span both, which is otherwise impossible. See also temporal versioning and time-travel queries, which formalises the same idea with valid time.

Is a history table the same as a bitemporal table?

No. A history table records transaction time only — when the database learned something. A bitemporal table adds valid time, which is when the fact was true on the ground. A history table cannot answer “what was the boundary on 1 April”, only “what did the database say on 1 April”. Layers where those differ — anything driven by surveys, ordinances or effective dates — need both.

Back to Choosing Formats & Tools for Spatial Versioning