The temporal_tables extension lets you track the full history of every row in a table. When you insert a row, the database records when it became valid. When you update or delete a row, the old version is automatically archived in a linked history table — no application-level logic required.
Common use cases:
Audit trails: Record who changed what and when, for compliance or forensics.
Point-in-time queries: Reconstruct the exact state of your data at any past moment.
Error recovery: Restore accidentally modified or deleted rows from the history table.
Data warehousing: Load historical data from an existing system using its original timestamps.
Prerequisites
Before you begin, ensure that you have:
A PolarDB for PostgreSQL cluster running PostgreSQL 14 (minor version 14.13.25.0 or later)
To check the minor version of your cluster, run:
-- PostgreSQL 14
SELECT version();
-- PostgreSQL 11
SHOW polar_version;How it works
A system-period temporal table is a pair of tables:
Current table: stores the live rows, each with a
sys_periodcolumn of typetstzrangethat records the row's validity period.History table: stores archived versions of rows that have been updated or deleted.
A trigger on the current table calls the versioning() function before every INSERT, UPDATE, or DELETE:
INSERT: the system generates the start timestamp using
CURRENT_TIMESTAMP(the time the first statement in the current transaction ran). The end timestamp is left open (infinity).UPDATE: the trigger copies the old row to the history table with a closed
sys_period, then updates the current row with a new start timestamp.DELETE: the trigger copies the row to the history table with a closed
sys_period, then removes it from the current table.
If a single transaction updates the same row multiple times, only one history row is written.
Set up a system-period temporal table
Step 1: Install the extension
CREATE EXTENSION temporal_tables;Step 2: Create the current table
CREATE TABLE employees
(
name text NOT NULL PRIMARY KEY,
department text,
salary numeric(20, 2)
);Step 3: Add the system period column
ALTER TABLE employees ADD COLUMN sys_period tstzrange NOT NULL;Step 4: Create the history table
The easiest way to create a compatible history table is to use the LIKE clause:
CREATE TABLE employees_history (LIKE employees);The history table does not need to be identical to the current table. For example, you can archive only a subset of columns, or add columns that track metadata such as the modifying user. The history table must satisfy two requirements:
It must contain a system period column with the same name and data type (
tstzrange) as the current table.Any column that exists in both tables must have the same data type.
Step 5: Create the versioning trigger
CREATE TRIGGER versioning_trigger
BEFORE INSERT OR UPDATE OR DELETE ON employees
FOR EACH ROW EXECUTE PROCEDURE versioning(
'sys_period', -- system period column name
'employees_history', -- history table name
true -- adjust: handle concurrent update conflicts automatically
);`versioning()` parameters
| Parameter | Description |
|---|---|
system_period_column_name | The name of the tstzrange column that records the row's validity period. |
history_table_name | The name of the history table where old row versions are archived. |
adjust | Controls behavior when a concurrent transaction causes the period end value to be earlier than the start value. Set to true to automatically adjust (usually adds 1 microsecond to the start time). Set to false to return a SQLSTATE 22000 error instead. |
Insert, update, and delete data
Insert data
Insert rows the same way you would with any regular table:
INSERT INTO employees (name, department, salary)
VALUES ('Bernard Marx', 'Hatchery and Conditioning Centre', 10000);
INSERT INTO employees (name, department, salary)
VALUES ('Lenina Crowne', 'Hatchery and Conditioning Centre', 7000);
INSERT INTO employees (name, department, salary)
VALUES ('Helmholtz Watson', 'College of Emotional Engineering', 18500);After inserting (assuming the statements ran on 2006-08-08), the current table looks like this:
name | department | salary | sys_period
-----------------+----------------------------------+----------+-----------------------------
Bernard Marx | Hatchery and Conditioning Centre | 10000.00 | ["2006-08-08 00:00:00+00",)
Lenina Crowne | Hatchery and Conditioning Centre | 7000.00 | ["2006-08-08 00:00:00+00",)
Helmholtz Watson | College of Emotional Engineering | 18500.00 | ["2006-08-08 00:00:00+00",)The history table is empty — no changes have been made yet.
Update data
-- Updated on 2007-02-27
UPDATE employees SET salary = 11200 WHERE name = 'Bernard Marx';After the update:
employees (current table)
name | department | salary | sys_period
-----------------+----------------------------------+----------+-----------------------------
Lenina Crowne | Hatchery and Conditioning Centre | 7000.00 | ["2006-08-08 00:00:00+00",)
Helmholtz Watson | College of Emotional Engineering | 18500.00 | ["2006-08-08 00:00:00+00",)
Bernard Marx | Hatchery and Conditioning Centre | 11200.00 | ["2007-02-27 00:00:00+00",)employees_history
name | department | salary | sys_period
-------------+----------------------------------+----------+-----------------------------------------------------
Bernard Marx | Hatchery and Conditioning Centre | 10000.00 | ["2006-08-08 00:00:00+00","2007-02-27 00:00:00+00")The archived row's sys_period is a closed interval — it records exactly when that version of the row was valid.
Delete data
-- Deleted on 2012-12-24
DELETE FROM employees WHERE name = 'Helmholtz Watson';After the deletion:
employees (current table)
name | department | salary | sys_period
--------------+----------------------------------+----------+-----------------------------
Lenina Crowne | Hatchery and Conditioning Centre | 7000.00 | ["2006-08-08 00:00:00+00",)
Bernard Marx | Hatchery and Conditioning Centre | 11200.00 | ["2007-02-27 00:00:00+00",)employees_history
name | department | salary | sys_period
-----------------+----------------------------------+----------+-----------------------------------------------------
Bernard Marx | Hatchery and Conditioning Centre | 10000.00 | ["2006-08-08 00:00:00+00","2007-02-27 00:00:00+00")
Helmholtz Watson | College of Emotional Engineering | 18500.00 | ["2006-08-08 00:00:00+00","2012-12-24 00:00:00+00")Query historical data
The sys_period column is a tstzrange value, so you can filter it using PostgreSQL's range operators.
View all versions of a specific row:
-- Combine the current table and history table
SELECT name, salary, sys_period
FROM employees
WHERE name = 'Bernard Marx'
UNION ALL
SELECT name, salary, sys_period
FROM employees_history
WHERE name = 'Bernard Marx'
ORDER BY sys_period;Find the row state at a specific point in time:
-- What did the employees table look like on 2007-01-01?
SELECT name, salary, sys_period
FROM employees_history
WHERE name = 'Bernard Marx'
AND sys_period @> '2007-01-01 00:00:00+00'::timestamptz;The @> operator checks whether the timestamp falls within the range. For rows that are still active, also query the current table.
List all changes in a time range:
SELECT name, salary, sys_period
FROM employees_history
WHERE sys_period && tstzrange('2006-01-01', '2008-01-01')
ORDER BY lower(sys_period);Handle concurrent update conflicts
When two transactions update the same row concurrently, the history row that the trigger tries to write may end up with an invalid range — where the end time is earlier than the start time.
Example scenario:
| Time | Transaction A | Transaction B |
|---|---|---|
| T1 | INSERT INTO employees (name, salary) VALUES ('Bernard Marx', 10000); | |
| T2 | INSERT INTO employees (name, salary) VALUES ('Lenina Crowne', 7000); | |
| T3 | COMMIT; | |
| T4 | UPDATE employees SET salary = 6800 WHERE name = 'Lenina Crowne'; | |
| T5 | INSERT INTO employees (name, salary) VALUES ('Helmholtz Watson', 18500); | |
| T6 | COMMIT; |
At T4, the trigger needs to set sys_period of the history row to [T2, T1). But T2 > T1, making it an invalid range.
With
adjust = false: the UPDATE at T4 fails withSQLSTATE 22000.With
adjust = true: the trigger automatically sets the start time to T2 + a small time interval (usually 1 microsecond), producing a valid period[T2, T2 + delta)in the history table. The current row getssys_period = [T2 + delta, ).
Set adjust = true in the trigger definition (as shown in the setup example) to avoid failures in concurrent write workloads.
Advanced usage
Set a custom system time
By default, the trigger uses CURRENT_TIMESTAMP as the period start time. To use a different timestamp — for example, when migrating historical data from a source system that recorded its own timestamps — call set_system_time() at the start of the transaction:
SELECT set_system_time('1985-08-08 06:42:00+08');To clear the custom time and revert to CURRENT_TIMESTAMP:
SELECT set_system_time(NULL);Transaction behavior: if the transaction that called set_system_time() is rolled back, the custom time is discarded. If the transaction commits, the custom time applies for the rest of the session.
Create a history table using inheritance
As an alternative to the LIKE clause, create the history table first and then derive the current table from it using PostgreSQL table inheritance:
-- Create the history table with the full schema
CREATE TABLE employees_history
(
name text NOT NULL,
department text,
salary numeric(20, 2),
sys_period tstzrange NOT NULL
);
-- The current table inherits from the history table
CREATE TABLE employees (PRIMARY KEY(name)) INHERITS (employees_history);Track who modified or deleted rows
Add triggers to record the session user on every change:
-- Record the user who inserted or updated a row
CREATE FUNCTION employees_modify()
RETURNS TRIGGER AS $$
BEGIN
NEW.user_modified = SESSION_USER;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER employees_modify
BEFORE INSERT OR UPDATE ON employees
FOR EACH ROW EXECUTE PROCEDURE employees_modify();
-- Record the user who deleted a row (fires when the trigger archives it to history)
CREATE FUNCTION employees_delete()
RETURNS TRIGGER AS $$
BEGIN
NEW.user_deleted = SESSION_USER;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER employees_delete
BEFORE INSERT ON employees_history
FOR EACH ROW EXECUTE PROCEDURE employees_delete();Maintain the history table
The history table grows continuously. Apply one or more of the following strategies to control its size:
Periodic deletion: run a scheduled job to delete rows older than a retention threshold.
Partitioning: partition the history table by time and detach old partitions when they are no longer needed.
Rule-based pruning: define rules such as retaining only the latest N versions of each row, or deleting history rows when the corresponding current row is deleted.
Cheaper storage: move the history table to a separate tablespace backed by lower-cost storage. See Cold data tiered storage.