Temporal Data Patterns in Databases: Tracking Historical Changes
What was this price last week? Ordinary tables keep only current values; temporal tables retain history. This article explains audit logs, versioned rows, valid-time ranges, PostgreSQL temporal extensions, the SQL:2011 standard, CDC alternatives, and when temporal tables are warranted versus when an audit log is enough.
Key Points
- Topic Focus: DevOps
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Temporal Data Patterns in Databases: Tracking Historical Changes
Let's start with a scenario you will probably encounter.
Finance comes to you: "What was the purchase price of this SKU last Wednesday? We need to reconcile an account." You open the product_prices table, find a row with price = 89.5, and confidently answer "89.5." She frowns: "No. We changed the price last Tuesday, so it should have been 92 on Wednesday." You freeze. The table contains only one record. The latest value, 89.5, was set yesterday, and Tuesday's change was overwritten by an UPDATE, leaving no trace.
You dig through logs, backups, and tickets, spending the entire afternoon piecing together the answer. Finance leaves you with one question: "Can we query it directly next time?"
This is not a bug. Your table design can answer "now," but not "the past." An ordinary table is a whiteboard that is constantly erased and rewritten. Every UPDATE is a lossy overwrite, and the old value disappears permanently. Most business systems are designed this way by default. It is only when a compliance audit, dispute, or retrospective query lands on your desk that you realize: if you do not deliberately preserve the history of some data, that data truly has no history.
This is the problem temporal data solves. This article thoroughly explains several database patterns for tracking historical changes, from lightweight audit logs to industrial-grade SQL:2011 system-versioned tables and CDC alternatives. It concludes with a framework for deciding when temporal modeling is warranted. The key lesson is simple: temporal data is not free, but omitting it when you need it means gambling with the business timeline.
1. What Is Temporal Data? More Than the "Current Value"
Let's align on the terminology first. An ordinary table stores the current state. The row in users contains the user's information at this moment, and the row in prices contains the product's current price. An UPDATE removes the old value and replaces it with the new one, so the table always reflects only "now."
Temporal data stores a sequence of states that evolves over time. It answers not only "How much does this product cost now?" but also "How much did it cost at any given point in time?" In other words, a temporal table can answer questions such as:
- What was the price of this SKU last Wednesday?
- What shipping address did this user provide last March?
- Between which dates was this contract in effect?
- When did this order's status change from "Pending Payment" to "Shipped"?
These questions share one characteristic: they ask about the state at a particular time, not the current state. Ordinary tables cannot answer them because they discard the time dimension.
Two Time Dimensions: Valid Time and System Time
You cannot fully understand temporal data without these two concepts. Many teams get temporal designs wrong because they fail to distinguish between them.
Valid time (business time) is the period during which data is true in the business world. For example, a contract with valid_from = 2025-01-01 and valid_to = 2025-12-31 is valid in the real world from New Year's Day through the end of the year. You can enter a price today that "takes effect next week." Its valid time is in the future, even though you insert it now.
System time (transaction time) is the period during which data is recorded or modified in the database. Suppose you change a price from 89 to 92 today at 10:00. The system-time interval for the value 89 is [some past moment, today at 10:00), while the interval for 92 is [today at 10:00, infinity). The database manages system time; users should not enter it manually.
The classic distinction is this: a contract might take effect on January 1 (valid time), but you might not enter it into the system until January 5 (system time). Alternatively, you might enter terms in December that take effect next year, placing valid time in the future and system time in the past. Modeling both dimensions is called bi-temporal modeling. It can answer difficult questions such as "What price did the system record last Wednesday as being valid at that time?" This is common in finance, insurance, and healthcare. Most systems need only one of the two dimensions.
Business timeline (valid time)
Contract starts ─────────────────────────── Contract expires
═══════════════════════════════════════════
Entered in system Price changed
───────────────────────────●─────────────────●──────────►
│ │
System timeline (system time)
The following four patterns all address the same underlying question: which time dimension should be retained, and in what structure?
2. Pattern One: Audit Logs (The Lightest Option, Start Here)
This is the right solution for 80% of use cases, yet it is also the easiest to overlook. The idea is simple: leave the primary table unchanged as an ordinary table, where UPDATE still overwrites values, and create a separate table that records every change event.
-- Primary table: stores only the current value as usual
CREATE TABLE product_prices (
id bigint PRIMARY KEY,
product_id bigint NOT NULL,
price numeric(10,2) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Audit log table: records every change
CREATE TABLE price_change_log (
id bigserial PRIMARY KEY,
target_id bigint NOT NULL, -- Which price was changed
old_price numeric(10,2), -- Value before the change (NULL = inserted)
new_price numeric(10,2), -- Value after the change (NULL = deleted)
changed_by text, -- Who made the change
changed_at timestamptz NOT NULL DEFAULT now()
);
The application can insert a log entry in the same transaction, or you can use a trigger to record it automatically:
CREATE OR REPLACE FUNCTION log_price_change() RETURNS trigger AS $$
BEGIN
INSERT INTO price_change_log (target_id, old_price, new_price, changed_by)
VALUES (OLD.id, OLD.price, NEW.price, current_user);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER price_audit
AFTER UPDATE ON product_prices
FOR EACH ROW EXECUTE FUNCTION log_price_change();
All of its advantages come from being lightweight:
- It does not intrude on the primary table. Existing writes continue to work as before,
UPDATEstill overwrites values, and performance remains unchanged. - Audit questions such as "Who changed what, and when?" require only one SQL statement:
SELECT * FROM price_change_log WHERE target_id = 42 ORDER BY changed_at. - Storage growth is manageable because it records only change events. Tables that change infrequently barely grow.
Its major limitation is that answering "What was the state at a particular time?" is difficult. To determine the price last Wednesday, you must sort all logs for target_id = 42, locate the final change before Wednesday, and take its new_price. In other words, you must replay the change history to reconstruct a snapshot. As the number of changes grows, replay logic becomes both slow and error-prone.
The role of an audit log is therefore clear: it is a ledger that records events, not a snapshot that stores state. If you need compliance records, accountability, or an answer to "Who touched this data?", an audit log is enough. If you need to return to any historical moment and inspect the state at that time, an audit log falls short.
In my experience, starting with an audit log is always worthwhile. Its cost is extremely low and its value is predictable. Even if you later adopt temporal tables, the audit log remains complementary: the temporal table answers questions about state, while the audit table answers questions about events.
3. Pattern Two: Versioned Rows (INSERT New Rows Instead of UPDATE)
With audit logs, events live outside the main data. With versioned rows, history lives within it. The core operation is simple: replace UPDATE with INSERT, retain the old row, and append a new one.
CREATE TABLE contract_versions (
id bigserial PRIMARY KEY,
contract_id bigint NOT NULL, -- The same logical contract
version int NOT NULL, -- Version number
content text NOT NULL,
created_by text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Unique constraint: a contract cannot have the same version twice
CREATE UNIQUE INDEX ON contract_versions (contract_id, version);
Need to modify the contract? Do not UPDATE it. INSERT a new row with version + 1:
INSERT INTO contract_versions (contract_id, version, content, created_by)
SELECT contract_id, version + 1, $1_new_content, $2_user
FROM contract_versions
WHERE contract_id = 99
ORDER BY version DESC
LIMIT 1;
Query the "latest version":
SELECT content FROM contract_versions
WHERE contract_id = 99
ORDER BY version DESC LIMIT 1;
Query "which version was in use last Wednesday":
SELECT content FROM contract_versions
WHERE contract_id = 99 AND created_at <= '2025-07-16'
ORDER BY version DESC LIMIT 1;
This pattern suits domains with a strong concept of versions, such as contracts, documents, configurations, policies, and wikis. The natural semantics of this data are V1, V2, and V3, and business users already talk about "rolling back to the previous version." The version number itself is a business concept, so storing it is entirely natural.
Its costs are:
- The "current value" is no longer a single row; it is the row with the highest version. Every query for the current value must include
ORDER BY version DESC LIMIT 1, or you must maintain anis_currentflag. This is easy to forget and easy to get wrong. - Rows accumulate for the same logical entity. Queries must avoid scanning historical versions, which means an index on
(contract_id, version)must locate the latest row efficiently. - It is unsuitable for data with frequent small changes. If a price changes three times a day or inventory changes every minute, version counts grow explosively, potentially reaching thousands per day. Such data is better served by the time-range pattern in the next section.
Versioned rows sit between audit logs and temporal tables. They provide snapshot capability that audit logs lack, because each row is a complete snapshot, but they lack the native interval-query support of true temporal tables. For cases with clear version boundaries and infrequent changes, they offer excellent value for the cost.
4. Pattern Three: Valid-Time Ranges (valid_from / valid_to, the Industrial Workhorse)
This is the most common pattern for implementing temporal data manually, and it is the underlying prototype for the SQL:2011 temporal tables discussed later. Each row carries a time interval indicating when that row is true.
CREATE TABLE product_prices (
id bigserial PRIMARY KEY,
product_id bigint NOT NULL,
price numeric(10,2) NOT NULL,
valid_from timestamptz NOT NULL,
valid_to timestamptz NOT NULL DEFAULT 'infinity' -- infinity means "valid through the present"
);
CREATE INDEX ON product_prices (product_id, valid_from, valid_to);
valid_to = 'infinity' is a common sentinel value meaning "this row has not expired and is currently valid." PostgreSQL's timestamptz natively supports 'infinity', including comparisons and indexing.
When changing a price, do not UPDATE the existing row's price. Use two steps instead:
- Close the currently valid row by setting its
valid_toto the current moment withUPDATE ... SET valid_to = now(). INSERTa new row withvalid_from = now()andvalid_to = 'infinity'.
-- Price change: set SKU 42 to 92 from now on
WITH closed AS (
UPDATE product_prices
SET valid_to = now()
WHERE product_id = 42 AND valid_to = 'infinity'
RETURNING product_id
)
INSERT INTO product_prices (product_id, price, valid_from, valid_to)
VALUES (42, 92.00, now(), 'infinity');
These two steps must run in one transaction. Otherwise, you can create a gap during which no price is valid.
Querying "What Was the Price Last Wednesday?"
This is the signature temporal query. The standard form is:
SELECT price
FROM product_prices
WHERE product_id = 42
AND valid_from <= '2025-07-16'::timestamptz
AND valid_to > '2025-07-16'::timestamptz;
The logic is to find the row that began before Wednesday and had not yet ended on Wednesday. This is the standard answer to the opening question about last Wednesday's price.
Preventing Overlaps: Exclusion Constraints
The most common failure in this pattern is overlapping valid intervals for the same product. For example, two accidental price changes might produce two rows that both claim to be valid on July 16. The query then returns two records, leaving the business with an ambiguous answer. Transactions written by hand cannot eliminate this risk completely, so the database must enforce a constraint.
PostgreSQL's strongest tool here is an exclusion constraint with a GiST index. A more elegant design uses the tstzrange range type instead of two timestamp columns:
CREATE EXTENSION IF NOT EXISTS btree_gist; -- Enable = operations in GiST
CREATE TABLE product_prices (
id bigserial PRIMARY KEY,
product_id bigint NOT NULL,
price numeric(10,2) NOT NULL,
valid_range tstzrange NOT NULL DEFAULT tstzrange(now(), NULL)
);
-- Valid intervals for the same product_id must not overlap
ALTER TABLE product_prices
ADD CONSTRAINT no_overlap
EXCLUDE USING GIST (
product_id WITH =,
valid_range WITH &&
);
&& is the range-overlap operator, while product_id WITH = limits the check to the same product. With this constraint, the database rejects any record whose interval overlaps an existing one, eliminating the possibility of two prices for the same period at the source.
Range operators also make queries more concise:
-- @> means that the range contains the given moment
SELECT price FROM product_prices
WHERE product_id = 42
AND valid_range @> '2025-07-16'::timestamptz;
This pattern is the industrial workhorse of manually implemented temporal data. It handles frequently changing data better than versioned rows and provides native snapshot capability that audit logs lack. The cost is that every write must use the two-step transaction of closing the old row and inserting a new one. The application must encapsulate this operation, because missing it even once can create a data gap.
5. Pattern Four: SQL:2011 Temporal Tables (Native System-Versioned Tables)
The first three patterns are assembled manually. The SQL:2011 standard standardized this capability as the system-versioned table. The database manages history for you: you write current values, and historical queries use dedicated syntax.
The core mechanism is system time. Each row has a SYSTEM_TIME interval indicating the transaction period during which the database considered that row the current value. When a row is updated or deleted, the database automatically moves the old version into an associated history table and fills in its system-time interval. Application code remains completely unaware of the process and operates on the table as usual.
-- SQL:2011 standard syntax (illustrated in a form similar to MariaDB, SQL Server, and DB2)
CREATE TABLE product_prices (
id bigint PRIMARY KEY,
product_id bigint NOT NULL,
price numeric(10,2) NOT NULL,
valid_from timestamptz NOT NULL DEFAULT now(),
valid_to timestamptz NOT NULL DEFAULT 'infinity',
PERIOD FOR SYSTEM_TIME (valid_from, valid_to)
) WITH SYSTEM VERSIONING;
To query "What was the price last Wednesday?", note the dedicated historical-query syntax:
SELECT price FROM product_prices
FOR SYSTEM_TIME AS OF '2025-07-16'
WHERE product_id = 42;
FOR SYSTEM_TIME AS OF is the key SQL:2011 syntax. The database automatically retrieves the version for that moment from the current and history tables. You do not need to know where history is stored or how to combine it. That is its greatest value.
Databases with native support for system-versioned tables:
| Database | Support |
|---|---|
| DB2 | Early adopter with the most complete support |
| SQL Server (2016+) | SYSTEM_VERSIONING = ON, with a history table required |
| MariaDB (10.3+) | WITH SYSTEM VERSIONING, available out of the box |
| Oracle | Does not fully follow SQL:2011, but Flashback provides similar capabilities |
| PostgreSQL | No native support for SQL:2011 system-versioned tables (as of PG 17) |
Pay attention to the final row. PostgreSQL still does not natively support WITH SYSTEM VERSIONING. This has long been a point of criticism in the PostgreSQL ecosystem. PostgreSQL users must rely on extensions or manual implementations, which the next section covers.
There is an essential distinction between system-versioned tables and Pattern Three's valid-time ranges: a system-versioned table records system time, meaning database transaction time. A query for "last Wednesday" reconstructs the version recorded by the database at that time. Pattern Three's valid_from/valid_to records valid time, meaning business time, so you can insert a record that "takes effect next week." They are not interchangeable. A system-versioned table cannot answer "What will this contract be worth when it takes effect next month?" because the system does not yet know the future, but it can answer "What price did the system contain last Wednesday?" Remember: system versioning supports retrospective reconstruction, while valid time represents advance agreements.
6. Implementing Temporal Tables in PostgreSQL
PostgreSQL does not natively support SQL:2011 system-versioned tables, but that does not mean it cannot handle temporal data. On the contrary, the PostgreSQL community has developed mature approaches. There are three options: an extension, TimescaleDB, or manual triggers.
Option One: The temporal_tables Extension (Preferred)
The best-known PostgreSQL temporal extension is temporal_tables, written by Vladimir Arkhipov and maintained on GitHub for many years. It adds system-versioned table capabilities. You create a current table and a structurally identical shadow history table, attach a trigger, and let the extension handle the rest.
-- Current table: sys_period records the system-time interval
CREATE TABLE product_prices (
id bigint PRIMARY KEY,
product_id bigint NOT NULL,
price numeric(10,2) NOT NULL,
sys_period tstzrange NOT NULL DEFAULT tstzrange(now(), NULL)
);
-- History table: copies the current table's structure
CREATE TABLE product_prices_history (LIKE product_prices);
-- Attach the versioning trigger: automatically moves old versions to the history table on UPDATE/DELETE
CREATE TRIGGER versioning_trigger
BEFORE UPDATE OR DELETE ON product_prices
FOR EACH ROW EXECUTE FUNCTION versioning(
'product_prices_history', -- History table name
'sys_period', -- System-time column
true -- Whether to split UPDATE into DELETE+INSERT
);
After attaching it, you can run UPDATE product_prices SET price = 92 WHERE id = 42 as usual, and the extension automatically:
- Closes the old version's
sys_periodand inserts that version, with price=89.5, intoproduct_prices_history. - Updates the price in the current table and sets the lower bound of
sys_periodtonow().
Your application code remains completely unaware, while product_prices_history quietly accumulates the complete history. To query last Wednesday's price:
SELECT price FROM product_prices
WHERE product_id = 42
AND sys_period @> '2025-07-16'::timestamptz
UNION ALL
SELECT price FROM product_prices_history
WHERE product_id = 42
AND sys_period @> '2025-07-16'::timestamptz;
(The current table can also match because it contains the row that remains valid through the present, so query the union of both tables.) If writing the union every time is inconvenient, create a view that unions the two tables and query the view instead.
Note: you may find several similarly named community extensions, including
pg_temporalandtemporal_tables. The most widely used and thoroughly documented one istemporal_tables(github.com/arkhipov/temporal_tables). Confirm that you are installing this project so that a smaller project with a similar name does not lead you astray.
Option Two: TimescaleDB (For Strongly Time-Series-Oriented History)
If your historical data is fundamentally a time series, such as IoT device states, minute-by-minute price snapshots, or monitoring metrics, a TimescaleDB hypertable with automatic time partitioning may be more appropriate than versioned tables. It is not a replacement for a "system-versioned table," but a different model: rather than storing change events, it periodically records complete snapshots. With one price snapshot per minute, querying last Wednesday's price means selecting the snapshot closest to that time. This suits data that is inherently a continuous time series, but not infrequent changes that must be recorded at the exact instant they occur.
Option Three: Manual Triggers (No Extension Dependency)
If your platform does not allow extensions, as may be the case with a cloud database or restricted environment, implement the same logic as temporal_tables using pure SQL triggers:
CREATE OR REPLACE FUNCTION version_product_prices() RETURNS trigger AS $$
BEGIN
-- Insert the old version, with its system time closed, into the history table
INSERT INTO product_prices_history (id, product_id, price, sys_period)
VALUES (OLD.id, OLD.product_id, OLD.price,
tstzrange(lower(OLD.sys_period), now()));
-- Update the lower bound of sys_period for the current row
NEW.sys_period := tstzrange(now(), NULL);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER versioning_trigger
BEFORE UPDATE ON product_prices
FOR EACH ROW EXECUTE FUNCTION version_product_prices();
A manual implementation has no external dependencies and gives you complete control over behavior. The downside is that you must handle DELETE operations, boundary conditions, and concurrency races yourself. You will have to rediscover every pitfall that temporal_tables already handles. My recommendation is to use the extension when possible and write the implementation manually only when necessary. If you do write it yourself, use the temporal_tables source as a reference instead of improvising.
7. Querying Temporal Data: Four Typical Questions
Temporal data is modeled to support queries. The following four common questions and their SQL statements show which pattern fits your needs.
Question One: What was a product's price at a specific time? (point-in-time)
-- Pattern Three: valid-time range
SELECT price FROM product_prices
WHERE product_id = 42
AND valid_from <= '2025-07-16'::timestamptz
AND valid_to > '2025-07-16'::timestamptz;
This is the most common temporal query. Patterns Three and Four answer it cleanly. An audit log requires replay, while versioned rows require ORDER BY ... LIMIT 1, making the difference immediately apparent.
Question Two: What is this product's price-change history? (history of a row)
SELECT valid_from, valid_to, price
FROM product_prices
WHERE product_id = 42
ORDER BY valid_from;
Temporal tables support this naturally, but audit logs can answer it as well. They may even do better because they also record changed_by. This is one area where audit logs do not lose to temporal tables.
Question Three: What was the full-table snapshot last Wednesday? (full snapshot)
SELECT *
FROM product_prices
WHERE valid_from <= '2025-07-16'::timestamptz
AND valid_to > '2025-07-16'::timestamptz;
This reconstructs the entire table. Be aware that it scans the whole table, so large datasets require an index on (valid_from, valid_to) or time-based partitioning. Audit logs collapse completely under this query because they would have to replay the history of every record, which is nearly impractical.
Question Four: When did the price first rise above 100? (first occurrence)
SELECT MIN(valid_from) AS first_above_100
FROM product_prices
WHERE product_id = 42 AND price >= 100;
A temporal table answers this with one SQL statement, while an audit log requires replay logic. These cross-version aggregate queries are a strength of temporal tables and a critical weakness of audit logs.
The decision from this section can be summarized in one sentence: if your core query is "return to a specific moment and inspect the state" or "reconstruct a full-table snapshot," you need a temporal table using Pattern Three or Four. If your core query is only "who changed what," an audit log is enough.
8. The CDC Alternative: Keep the Application Unaware
There is another completely different approach: leave the table structure unchanged, avoid triggers, and use change data capture (CDC) to extract a side-channel change stream from the database log.
A typical architecture uses Debezium + Kafka. Debezium monitors PostgreSQL's WAL or MySQL's binlog, parses every INSERT/UPDATE/DELETE into a structured event, and publishes it to Kafka. Downstream consumers write those events to a dedicated historical store such as Elasticsearch, Iceberg, ClickHouse, or another PostgreSQL database.
Application ──normal reads/writes──▶ PostgreSQL ──WAL──▶ Debezium ──▶ Kafka ──▶ Historical store
(main table unchanged) (query history here)
CDC's appeal comes entirely from being nonintrusive:
- No application changes.
UPDATEcontinues to overwrite values, the primary table still stores only the current value, and developers do not need to think about temporal data. - It works across databases. The same Debezium setup can capture both PostgreSQL and MySQL, while the historical store can use an engine better suited to analytics.
- It naturally supports data analysis. Once the change stream lands in warehouse tables, BI, reporting, and machine learning workloads can query it without competing for resources on the operational database.
Its costs all come from being heavyweight:
- Latency. Debezium to Kafka to downstream storage is an asynchronous pipeline. Seconds of delay are normal, and delays of several minutes are not unusual. If you ask for the price from three seconds ago, CDC may not have persisted it yet.
- Explosive infrastructure complexity. Kafka, Connect, Schema Registry, downstream storage, monitoring, and alerting impose substantial operational costs. Deploying an entire CDC pipeline for one small team's price history is overkill.
- Poor point-in-time query support. CDC stores an event stream. Answering "What was the price last Wednesday?" still requires reconstructing a snapshot downstream, just like an audit log, unless the downstream system builds a dedicated temporal index.
When is CDC appropriate? Use it when data must enter a warehouse for analysis, multiple downstream consumers need it, seconds of latency are acceptable, and the team already operates Kafka infrastructure. In that setting, CDC outperforms temporal tables because temporal history remains inside the OLTP database and is less convenient to share across systems.
When is CDC inappropriate? Avoid it for strongly consistent point-in-time queries, low-latency requirements, self-contained workflows within one database, and small teams. In those cases, build a temporal table in PostgreSQL instead of adding a Kafka layer.
The approaches are not mutually exclusive. A common production design is to use temporal tables or audit logs inside the OLTP database for business reconstruction while CDC sends the change stream to a warehouse for long-term analysis. Each serves its own purpose.
9. When to Use Temporal Tables and When an Audit Log Is Enough
After considering all these approaches, the most important question is: which level does your use case actually require? Here is a practical decision framework.
Use temporal tables (Pattern Three or Four) for:
- Finance, reconciliation, and settlement. Prices, interest rates, exchange rates, and fees require historical queries. A single mistake costs money.
- Compliance and auditing. SOX, GDPR, medical records under HIPAA, and tax regulations may require the ability to reconstruct state at any historical moment.
- Contracts, insurance policies, and agreements. This data naturally has valid-interval semantics, from effective date to expiration date, making valid time a core business concept.
- Pricing, promotions, and tariffs. Product prices and tax rates change over time, and finance and operations must reconstruct which price should have applied to a transaction.
- Master data management (MDM). Downstream systems need to consume the authoritative historical version of changes to customers, materials, and organizational structures at a given time.
An audit log is enough for:
- Simple CRUD systems. For user profiles, articles, and comments,
updated_atplus an audit log is enough to answer "Who changed this?" - State-machine transitions. When an order moves from "Pending Payment" to "Shipped," the event stream itself is the audit log, so a temporal table is unnecessary.
- Drafts and temporary data. Their lifecycle is short, so preserving history has no value.
Use versioned rows (Pattern Two) for:
- Documents, contracts, configurations, and policies, where versions are a business concept and users say "roll back to V2."
Use CDC for:
- Historical data that must enter a warehouse or serve multiple downstream systems, where latency is acceptable and Kafka infrastructure already exists.
Beware of a common form of overengineering: adding valid_from/valid_to to every table because history might be needed someday. Every query then needs a time predicate, every write becomes a two-step transaction, and the cognitive load on developers doubles, even though no one ever queries the history of 90% of those tables. Temporal modeling has a cost, so apply it only to tables that truly need it. My decision rule is simple: if being unable to answer "What was this value last Wednesday?" would cause a serious problem, such as financial loss, a regulatory violation, or a dispute, use a temporal table. If the concern is only unease about losing history, an audit log provides enough reassurance.
10. Costs: Storage Growth and Query Complexity
Finally, let's make the costs explicit so that temporal tables are not mistaken for a silver bullet.
Cost One: Storage grows linearly with changes. Patterns Two, Three, and Four share one property: every change adds at least one row. A SKU whose price changes three times a day accumulates more than a thousand historical rows per year. Without controls, the history for a frequently changing table such as real-time inventory can grow to terabytes within months. Mitigations include:
- Partition the history table by time. Partition
product_prices_historymonthly byvalid_to. Old partitions can be compressed, archived, or moved to cold storage independently. PostgreSQL's native declarative partitioning is fully adequate. - Set a retention period. If compliance requires seven years, keep seven years. For business history without such requirements, one or two years is often enough. Periodically
DETACHand archive old data. - Separate hot and cold data. Keep the current table small and allow the history table to grow. This is an implicit advantage of Pattern Four's system-versioned tables over Pattern Three's single-table intervals because historical data is naturally separated.
Cost Two: Queries become more complex. Every query needs time predicates. In complex business logic, nested temporal conditions reduce SQL readability. The indexing strategy must also change: point-in-time queries require a composite (entity_id, valid_from, valid_to) index or a GiST range index. Get it wrong, and the database performs a full-table scan. EXPLAIN is your friend on this path.
Cost Three: The write path becomes heavier. Pattern Three's two-step transaction that closes the old row and inserts a new one, along with Pattern Four's triggers, makes each write more expensive than an ordinary UPDATE. Measure throughput on frequently written tables. If necessary, persist history asynchronously, although that introduces consistency problems and requires a tradeoff.
Cost Four: Concurrency and consistency. Without locking, two concurrent price changes in Pattern Three can each close the current row and insert a new one, creating overlapping intervals or gaps. This is why the earlier section emphasized exclusion constraints. Let the database enforce concurrency correctness instead of expecting application-level locks to get it right.
These costs explain why I repeatedly recommend starting with an audit log: the cost of an audit log is almost zero, while the cost of temporal tables is real and ongoing. Upgrade only when the benefit of answering historical-state queries clearly exceeds those costs.
Summary
-
Temporal data answers "What was the state at a particular time?", not "What is the state now?" Ordinary tables overwrite old values with UPDATE and discard history. Temporal modeling preserves the time dimension so that any moment can be reconstructed. Before modeling, distinguish valid time, meaning business time, from system time. Bi-temporal modeling is necessary only in highly sensitive domains such as finance and healthcare.
-
The four pattern levels increase in strength. Audit logs are the lightest, record events, and make state reconstruction difficult. Versioned rows INSERT new versions and suit contracts and documents. Valid-time ranges use valid_from/valid_to with exclusion constraints and are the main manual production pattern. SQL:2011 system-versioned tables let the database manage history and support
FOR SYSTEM_TIME AS OFqueries. Greater capability comes with greater cost. -
PostgreSQL lacks native SQL:2011 system-versioned tables, but it has three mature approaches. Prefer the
temporal_tablesextension, which combines a current table, shadow history table, and trigger without affecting the application. Consider TimescaleDB for strongly time-series-oriented data. In restricted environments, use pure SQL triggers, but consult a mature implementation instead of rediscovering its pitfalls. -
CDC is another path, not a replacement for temporal tables. Debezium + Kafka does not intrude on applications, works across databases, and naturally feeds warehouses, but it introduces seconds of latency, heavy operations, and poor point-in-time queries. Use temporal tables for reconstruction within OLTP systems and CDC for cross-system analytics. They commonly coexist and serve different roles.
-
Whether you need temporal data depends on whether losing historical answers would cause a serious problem. Finance, compliance, contracts, and pricing require temporal tables. Simple CRUD systems, state machines, and drafts are adequately served by audit logs plus
updated_at. Do not add time intervals to every table merely for peace of mind; that is overengineering. The costs are real: storage grows linearly with changes, queries become more complex, writes become heavier, and concurrency requires exclusion constraints as a safeguard.
Takeaway: An ordinary table is a whiteboard whose erased contents are gone. A temporal table is a recording that can be replayed. Recordings are not free, but if you fail to install a camera where one is needed, what will you replay after something goes wrong? The entire craft of temporal design lies in deciding which data deserves to be recorded and which can simply be erased.
Related
Related Content
Article
Considerations for Deploying a Next.js Community Project with Docker
Technical trends, engineering practices, and an independent developer perspective on DevOps and Next.js.
Article
How Can OpenTelemetry Help You Observe AI Applications?
Technical trends, engineering practices, and an independent developer's perspective on DevOps and AI.
Article
Long-Running Tool Calls: The Terminal-State Protocol for Operation, Poll, and Cancel
After the initial request times out, the tool may still be running server-side; issuing a cancel does not mean the side effects have stopped. This article establishes an operation identity, polling watermark, cancellation race handling, and a single terminal-state acceptance point for a single long-running tool call.
Article
Agent Memory Erasure Propagation: From User Forgetting Request to Verifiable Purge
Deleting one row in the primary table won't clear vectors, summaries, caches, or restored backups. This article uses a deletion state machine, generational tombstones, derived lineage, and end-to-end canary verification to truly forget.