The temporal_tables extension lets you create system-period temporal tables in PolarDB for PostgreSQL (Compatible with Oracle). A system-period temporal table automatically tracks when each row was valid from the database's perspective. On INSERT, the system generates the validity period. On UPDATE or DELETE, the old row is archived to a history table before the change is applied.
Use cases
Data audit: Track every change to critical rows, including who changed what and when.
Point-in-time recovery: Reconstruct the exact state of data at any past moment.
Trend analysis: Query how values evolved over time without building a separate audit log.
Accidental change recovery: Restore rows deleted or modified by mistake by reading the history table.
Prerequisites
Before you begin, ensure that you have:
A PolarDB for PostgreSQL (Compatible with Oracle) 2.0 cluster running minor version 2.0.14.25.0 or later
To check your minor version, run:
SHOW polar_version;Install the extension
CREATE EXTENSION temporal_tables;Create a system-period temporal table
The temporal_tables extension manages row history through the versioning() trigger function:
versioning(<system_period_column_name>, <history_table_name>, <adjust>)Attach this trigger to the main table with BEFORE INSERT OR UPDATE OR DELETE. The parameters are:
| Parameter | Description |
|---|---|
system_period_column_name | Name of the system period column (must be tstzrange) |
history_table_name | Name of the history table that stores archived rows |
adjust | Controls behavior when a conflict causes the end time to precede the start time. Set to false to raise an error (SQLSTATE 22000); set to true to automatically adjust the start time to the previous end time plus 1 microsecond |
Example setup
Create the main table.
CREATE TABLE employees ( name text NOT NULL PRIMARY KEY, department text, salary numeric(20, 2) );Add the system period column.
ALTER TABLE employees ADD COLUMN sys_period tstzrange NOT NULL;Create the history table. The
LIKEclause is the simplest approach and copies the column definitions automatically.The history table does not need to match the main table exactly. It can include extra columns or omit some, as long as it satisfies two requirements: it must include a system period column with the same name and data type (
tstzrange), and any column that exists in both tables must have the same data type.CREATE TABLE employees_history (LIKE employees);Create the versioning trigger.
CREATE TRIGGER versioning_trigger BEFORE INSERT OR UPDATE OR DELETE ON employees FOR EACH ROW EXECUTE PROCEDURE versioning('sys_period', 'employees_history', true);
Insert data
Insert rows into a system-period temporal table exactly as you would with a regular table. The trigger automatically sets the sys_period start value to CURRENT_TIMESTAMP—the time the first data change statement executed in the current transaction.
Example
Insert three rows on August 8, 2006.
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);Query the main table:
SELECT * FROM employees;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",)
(3 rows)Query the history table:
SELECT * FROM employees_history;name | department | salary | sys_period
------+------------+--------+------------
(0 rows)Inserts write only to the main table. The history table is empty until the first UPDATE or DELETE.
Update data
When you update a row, the trigger copies the old version to the history table and sets its sys_period end value to the current timestamp. The new row gets a new start value. If a single transaction updates the same row multiple times, only one history row is generated.
Example
Update Bernard Marx's salary on February 27, 2007.
UPDATE employees SET salary = 11200 WHERE name = 'Bernard Marx';Main table after update:
SELECT * FROM employees;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",)
(3 rows)History table after update:
SELECT * FROM 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")
(1 row)Delete data
When you delete a row, the trigger moves it to the history table with its sys_period closed at the deletion timestamp.
Example
Delete Helmholtz Watson on December 24, 2012.
DELETE FROM employees WHERE name = 'Helmholtz Watson';Main table after deletion:
SELECT * FROM employees;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",)
(2 rows)History table after deletion:
SELECT * FROM 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")
(2 rows)Query history data
The history table is a regular table. Query it with standard SQL using the sys_period column to filter by time range.
Rows valid at a specific point in time — return all versions whose period contains the target timestamp:
SELECT * FROM employees_history
WHERE sys_period @> '2007-01-01 00:00:00+00'::timestamptz;All versions of a specific row — combine the main table and history table with UNION ALL:
SELECT name, department, salary, sys_period
FROM employees
WHERE name = 'Bernard Marx'
UNION ALL
SELECT name, department, salary, sys_period
FROM employees_history
WHERE name = 'Bernard Marx'
ORDER BY lower(sys_period);Rows active within a time range — rows whose period overlaps the range:
SELECT * FROM employees_history
WHERE sys_period && tstzrange('2006-01-01+00', '2007-06-01+00');Handle update conflicts
Conflicts can occur when concurrent transactions update the same row. Consider this 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 must write a history row with period [T2, T1). Because T2 > T1, this period is invalid.
With
adjust=false: the UPDATE at T4 fails withSQLSTATE 22000.With
adjust=true: the trigger sets the new start time toT2 + 1 microsecond, resolving the conflict automatically.
After T6 commits with adjust=true:
employees table:
| name | department | salary | sys_period |
|---|---|---|---|
| Bernard Marx | Hatchery and Conditioning Centre | 10000 | [T1, ) |
| Lenina Crowne | Hatchery and Conditioning Centre | 6800 | [T2 + delta, ) |
| Helmholtz Watson | College of Emotional Engineering | 18500 | [T1, ) |
employees_history table:
| name | department | salary | sys_period |
|---|---|---|---|
| Lenina Crowne | Hatchery and Conditioning Centre | 7000 | [T2, T2 + delta) |
Advanced usage
Set a custom system time
By default, the trigger uses CURRENT_TIMESTAMP. Override this to backfill historical data—for example, when migrating from a system that recorded its own timestamps.
SELECT set_system_time('1985-08-08 06:42:00+08');Reset to the default:
SELECT set_system_time(NULL);The custom time persists until the end of the session if the transaction commits. If the transaction is rolled back, the change is reverted.
Create a system-period temporal table using inheritance
As an alternative to the LIKE clause, use PostgreSQL inheritance to define the history table first, then derive the main table from it.
Create the history table explicitly:
CREATE TABLE employees_history
(
name text NOT NULL,
department text,
salary numeric(20, 2),
sys_period tstzrange NOT NULL
);Create the main table as a child of the history table:
CREATE TABLE employees (PRIMARY KEY(name)) INHERITS (employees_history);Maintain the history table
History tables grow continuously. Use one of the following strategies to keep storage under control.
Delete old rows by age:
-- Remove history rows older than one year
DELETE FROM employees_history
WHERE upper(sys_period) < now() - interval '1 year';Delete old rows when the source row is deleted — add a trigger on the history table that prunes versions beyond a retention count:
-- Retain only the 5 most recent versions of each row
DELETE FROM employees_history
WHERE name = 'Bernard Marx'
AND sys_period NOT IN (
SELECT sys_period FROM employees_history
WHERE name = 'Bernard Marx'
ORDER BY lower(sys_period) DESC
LIMIT 5
);Use partitioning — partition the history table by time range and detach old partitions when they are no longer needed. Detached partitions can be archived or dropped independently.
Move to cold storage — configure a separate tablespace for the history table to move it to lower-cost storage. For details, see Cold data tiered storage.
Use system-period temporal tables for data audit
Extend the setup with additional triggers to capture which user made each change.
-- Track the user who modified 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();
-- Track the user whose action caused a row to be archived
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();References
For more information about the temporal_tables extension, see the upstream project documentation.