All Products
Search
Document Center

PolarDB:temporal_tables

Last Updated:Mar 28, 2026

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_period column of type tstzrange that 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:

  1. It must contain a system period column with the same name and data type (tstzrange) as the current table.

  2. 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

ParameterDescription
system_period_column_nameThe name of the tstzrange column that records the row's validity period.
history_table_nameThe name of the history table where old row versions are archived.
adjustControls 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:

TimeTransaction ATransaction B
T1INSERT INTO employees (name, salary) VALUES ('Bernard Marx', 10000);
T2INSERT INTO employees (name, salary) VALUES ('Lenina Crowne', 7000);
T3COMMIT;
T4UPDATE employees SET salary = 6800 WHERE name = 'Lenina Crowne';
T5INSERT INTO employees (name, salary) VALUES ('Helmholtz Watson', 18500);
T6COMMIT;

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 with SQLSTATE 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 gets sys_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.

References