Modelling Valid Time and Transaction Time in PostGIS
Two clocks, two range columns, one constraint that makes the whole thing safe: the bitemporal spatial schema is small, and almost all of its value comes from the constraint people leave out. This page is a focused companion to temporal versioning and time-travel queries.
Concept & Context
A bitemporal table answers two questions that a single timestamp cannot separate: when was this true on the ground, and when did we believe it. Every spatial dataset driven by surveys, ordinances or field observations has a lag between those two, and every retroactive correction makes the gap explicit.
PostgreSQL is unusually well suited to modelling this because range types carry both bounds in one value, with operators (@>, &&) that read naturally and an index type that supports them. Most importantly, an exclusion constraint over a range column enforces non-overlap declaratively. Without it, a temporal table is a convention that holds until the first concurrent write, and the resulting duplicate versions are discovered by whoever runs the first as-of query on a boundary date.
The schema below is deliberately minimal: one entity column, the payload, two ranges, and the commit that asserted the row. Everything else β soft deletes, status flags, effective-date columns β turns out to be expressible in the two ranges, and adding it alongside them creates two sources of truth about the same thing.
Core Algorithmic Pipeline
- Declare both intervals as
tstzrangewith[)bounds. Adjacent versions then meet at exactly one instant, owned by exactly one row. - Add a GiST exclusion constraint over
(entity =, valid &&, tx &&), which makes two overlapping assertions about one entity impossible rather than merely unlikely. - Route every write through one function that closes the predecessor and inserts the successor inside one transaction.
- Correct retroactively by superseding: close the transaction interval of the incorrect row, insert a replacement carrying the same valid interval and the corrected values.
- Index for the two access patterns β current state, and as-of by extent β because they want different indexes and the second one is the expensive one.
Working Implementation
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-- Bitemporal parcel table: one row per (entity, valid interval, tx interval)
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
CREATE EXTENSION IF NOT EXISTS btree_gist; -- needed to mix `=` with `&&`
CREATE TABLE parcel_versions (
version_id bigserial PRIMARY KEY,
parcel_id uuid NOT NULL,
geom geometry(MultiPolygon, 3035) NOT NULL,
owner_ref text,
land_use text,
valid_period tstzrange NOT NULL,
tx_period tstzrange NOT NULL DEFAULT tstzrange(now(), 'infinity', '[)'),
commit_sha char(40) NOT NULL,
CONSTRAINT valid_bounds CHECK (lower_inc(valid_period) AND NOT upper_inc(valid_period)),
CONSTRAINT tx_bounds CHECK (lower_inc(tx_period) AND NOT upper_inc(tx_period)),
CONSTRAINT valid_nonempty CHECK (NOT isempty(valid_period))
);
-- The constraint that turns a convention into a guarantee: one entity may not
-- hold two assertions whose validity AND belief windows both overlap.
ALTER TABLE parcel_versions
ADD CONSTRAINT parcel_versions_no_overlap
EXCLUDE USING gist (
parcel_id WITH =,
valid_period WITH &&,
tx_period WITH &&
);
CREATE INDEX parcel_versions_geom_gix ON parcel_versions USING gist (geom);
CREATE INDEX parcel_versions_valid_gix ON parcel_versions USING gist (valid_period);
-- Current state is the overwhelming majority of reads: serve it from its own
-- small partial index rather than from the full range index.
CREATE INDEX parcel_versions_current_idx
ON parcel_versions (parcel_id)
WHERE upper_inf(valid_period) AND upper_inf(tx_period);
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-- The single write path
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
CREATE OR REPLACE FUNCTION record_parcel_version(
p_parcel_id uuid,
p_geom geometry,
p_owner_ref text,
p_land_use text,
p_valid_from timestamptz, -- when the change took effect on the ground
p_commit_sha char(40)
) RETURNS bigint AS $$
DECLARE
closed int;
new_id bigint;
BEGIN
-- Close the open validity of the current belief about this parcel.
UPDATE parcel_versions
SET valid_period = tstzrange(lower(valid_period), p_valid_from, '[)')
WHERE parcel_id = p_parcel_id
AND upper_inf(valid_period)
AND upper_inf(tx_period)
AND lower(valid_period) < p_valid_from; -- guard against an inverted range
GET DIAGNOSTICS closed = ROW_COUNT;
IF closed > 1 THEN
RAISE EXCEPTION
'parcel % had % open versions β the exclusion constraint is missing',
p_parcel_id, closed;
END IF;
INSERT INTO parcel_versions
(parcel_id, geom, owner_ref, land_use, valid_period, commit_sha)
VALUES
(p_parcel_id, p_geom, p_owner_ref, p_land_use,
tstzrange(p_valid_from, 'infinity', '[)'), p_commit_sha)
RETURNING version_id INTO new_id;
RETURN new_id;
END;
$$ LANGUAGE plpgsql;
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-- Retroactive correction: supersede the belief, keep the validity
-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
CREATE OR REPLACE FUNCTION correct_parcel_version(
p_version_id bigint,
p_geom geometry,
p_commit_sha char(40)
) RETURNS bigint AS $$
DECLARE
old parcel_versions%ROWTYPE;
new_id bigint;
BEGIN
SELECT * INTO old FROM parcel_versions WHERE version_id = p_version_id
FOR UPDATE;
IF NOT FOUND OR NOT upper_inf(old.tx_period) THEN
RAISE EXCEPTION 'version % is absent or already superseded', p_version_id;
END IF;
UPDATE parcel_versions
SET tx_period = tstzrange(lower(tx_period), now(), '[)')
WHERE version_id = p_version_id;
INSERT INTO parcel_versions
(parcel_id, geom, owner_ref, land_use, valid_period, commit_sha)
VALUES
(old.parcel_id, p_geom, old.owner_ref, old.land_use,
old.valid_period, p_commit_sha)
RETURNING version_id INTO new_id;
RETURN new_id;
END;
$$ LANGUAGE plpgsql;
The lower(valid_period) < p_valid_from guard in the write function is what stops a mis-dated edit from creating an inverted range. Without it, an edit dated before the current versionβs start silently produces an empty range, and an empty range overlaps nothing β so the exclusion constraint does not catch it and the parcel quietly acquires two open versions.
Validation & Output Verification
The constraint does the heavy lifting, so the first verification is that it exists and works:
-- It must reject an overlapping assertion
BEGIN;
INSERT INTO parcel_versions (parcel_id, geom, valid_period, commit_sha)
SELECT parcel_id, geom, valid_period, commit_sha
FROM parcel_versions LIMIT 1;
-- expected: ERROR ... conflicting key value violates exclusion constraint
ROLLBACK;
-- No entity may have more than one currently-believed current version
SELECT parcel_id, count(*) AS open_versions
FROM parcel_versions
WHERE upper_inf(valid_period) AND upper_inf(tx_period)
GROUP BY parcel_id
HAVING count(*) > 1;
-- expected: zero rows
-- No empty or inverted validity ranges
SELECT version_id FROM parcel_versions WHERE isempty(valid_period);
-- expected: zero rows
Then confirm the four temporal questions return what they should:
-- As-of and as-known-then must differ after a correction, and agree before one
SELECT
(SELECT count(*) FROM parcel_versions
WHERE valid_period @> TIMESTAMPTZ '2025-04-01' AND upper_inf(tx_period))
AS believed_now,
(SELECT count(*) FROM parcel_versions
WHERE valid_period @> TIMESTAMPTZ '2025-04-01'
AND tx_period @> TIMESTAMPTZ '2025-04-01')
AS believed_then;
If those two counts are always equal on a table that has had corrections applied, the correction path is updating rows rather than superseding them, and the audit trail is not being kept.
Finally, check the plan for the query that actually costs money:
EXPLAIN (ANALYZE, BUFFERS)
SELECT parcel_id FROM parcel_versions
WHERE valid_period @> TIMESTAMPTZ '2025-04-01'
AND upper_inf(tx_period)
AND ST_Intersects(geom, ST_MakeEnvelope(4321000, 3210000, 4326000, 3215000, 3035));
-- Both the geometry GiST index and the valid_period index should appear.
Failure Modes
-
The exclusion constraint refuses to be created β symptom:
ALTER TABLEfails on an existing table. Root cause: the loaded history already contains overlapping versions. Fix: treat the failure report as a work list; reconcile the named entities, then create the constraint. Do not proceed without it. -
btree_gistmissing β symptom:data type uuid has no default operator class for access method "gist". Root cause: mixing=with&&in one exclusion constraint needs the extension. Fix:CREATE EXTENSION btree_gistbefore the constraint. -
Two open versions appear despite the constraint β symptom: an as-of query returns duplicates. Root cause: an empty validity range, which overlaps nothing and therefore never conflicts. Fix: add the
NOT isempty(valid_period)check constraint and the lower-bound guard in the write function. -
Corrections erase the previous belief β symptom: an as-known-then query returns todayβs values for every past date. Root cause: corrections applied as
UPDATEon the row. Fix: revoke directUPDATEon the table and route corrections through the supersede function.
Related
- Temporal Versioning and Time-Travel Queries β the parent guide covering when this model is worth its complexity
- Querying a Parcel Layer as of a Past Commit β the repository-side approximation of the same question
- Implementing History Tables with PostGIS Triggers β transaction-time-only history, which is simpler and answers less
- Database-Native Versioning in PostGIS and GeoPackage β where this schema sits among the database-side options