Temporal Data
Rule: a system does not act on objective truth — it acts on what it believed at the time. Reconstructing a past decision needs two time axes: when the fact was true, and when you knew it.
Part of the data-modeling set: retention & history · temporal data (this file) · event sourcing & CQRS · message delivery
Contents
- The anti-pattern
- Why development hides it
- Valid time: modelling when a fact applied
- Why one timeline is not enough
- Bitemporal: two axes
- When you actually need it
- Implementation notes
- Review checklist
The anti-pattern
CREATE TABLE interest_rates (
product_id bigint PRIMARY KEY,
rate numeric NOT NULL -- the current rate. Only the current rate.
);
UPDATE interest_rates SET rate = 8.5 WHERE product_id = :id; -- the old rate is gone
Then legal asks: "Which rate did we apply to this loan when it was approved in March?" The table holds one number, and it is today's.
Even a history table only partly rescues this, because it records when the row changed, not when the rate was in force. A rate effective from 1 January that was entered into the system on 15 February has two different dates, and a single-timeline schema cannot express both.
Why development hides it
Tests assert on current values, and the current value is always correct. Nothing locally asks a question anchored to a past moment.
The failure is also silent and unrecoverable: nothing errors, and the information was never stored, so no amount of later work can produce the answer. Like the retention patterns, this is a schema decision that cannot be revisited after the fact.
Valid time: modelling when a fact applied
The first axis is valid time — the period during which a fact is true in the real world:
CREATE TABLE interest_rates (
product_id bigint NOT NULL,
rate numeric NOT NULL,
valid_from date NOT NULL,
valid_to date, -- NULL = still in force
PRIMARY KEY (product_id, valid_from)
);
Querying "the rate that applied on 15 March":
SELECT rate FROM interest_rates
WHERE product_id = :id
AND valid_from <= '2026-03-15'
AND (valid_to IS NULL OR valid_to > '2026-03-15');
This makes time a first-class part of the model rather than metadata about the row. It also lets
you insert a future-dated change — a rate effective next month — which a mutable rate column
cannot represent at all.
Two invariants worth enforcing rather than trusting: no overlapping periods for the same key,
and no gaps where a required fact has no value. In PostgreSQL an EXCLUDE constraint with a
range type enforces non-overlap directly; see
transaction-isolation for why an application-level check is not enough.
Why one timeline is not enough
The scenario that makes this concrete, and it is worth reading carefully:
An insurer approves a claim in March. In October, an auditor asks: "Why did you approve such a low payout for John Doe in March?"
The engineer checks and answers: "Because he was born in 1990 — low-risk band, cheaper premium, lower benefit."
The auditor replies: "But the system says 1988 today."
Both are correct. His date of birth was always 1988 — that is the fact. But in March the system held 1990, and it priced the policy on what it held. Someone corrected the record in June.
Now: was the March decision wrong? With one timeline you cannot tell. You know today's truth and have permanently lost what the system knew when it decided.
A system does not run on objective truth. It runs on what it knew at a specific point in time.
Correcting data is not the same as data changing. A customer moving house is a new fact with a new valid period. A typo in their birth date being fixed is a retroactive correction — the fact was always the corrected value, and only your knowledge changed. Conflating those two is the bug.
Bitemporal: two axes
Keep both:
- Valid time — when the fact was true in the world
- Transaction time (or decision time) — when the system recorded it
CREATE TABLE customer_facts (
customer_id bigint NOT NULL,
date_of_birth date NOT NULL,
valid_from date NOT NULL, -- real-world validity
valid_to date,
recorded_at timestamptz NOT NULL, -- when we learned it
superseded_at timestamptz, -- when this belief was replaced
PRIMARY KEY (customer_id, valid_from, recorded_at)
);
Two distinct questions, both answerable:
-- What is true now, as best we know?
WHERE valid_to IS NULL AND superseded_at IS NULL
-- What did we believe in March about the state of the world in March?
WHERE valid_from <= '2026-03-15' AND (valid_to IS NULL OR valid_to > '2026-03-15')
AND recorded_at <= '2026-03-31' AND (superseded_at IS NULL OR superseded_at > '2026-03-31')
The second query is the one that defends the March decision. It reconstructs not just the past state of the world but the past state of your knowledge — and that is what an audit actually examines.
When you actually need it
Bitemporal modelling is genuinely expensive: more storage, harder queries, and a real conceptual load on everyone who touches the schema. Do not apply it by default.
You need it when a past decision must be defensible after the underlying data has been corrected. In practice:
- Insurance — pricing and claims decided on customer facts that get corrected later
- Banking and lending — credit decisions, applied rates, limits under regulatory review
- Fund management — NAV recomputed after a corrected trade or price
- Payroll and tax — retroactive salary adjustments and backdated tax rules
- Healthcare — clinical decisions made on records later amended
You do not need it for product catalogues, user preferences, feature flags, or anything where "it changed and now it's different" is a complete account. For those, valid time alone or a plain history table is right.
The reviewable question: if this data were corrected next year, would anyone need to explain a decision made on the old value? If yes, one timeline is not enough.
Implementation notes
- Never
UPDATEin place. A correction inserts a new version and closes the previous belief by settingsuperseded_at. Same immutability logic as payment-ledger. - Index for the query you run. Point-in-time lookups need the key plus both time ranges; without that, every historical query degrades into a scan — see indexes-and-query-plans.
- Decide on inclusive/exclusive bounds once and document it. Half-open
[from, to)avoids the off-by-one-day ambiguity that otherwise appears in every report. - Use range types and exclusion constraints where available, rather than trusting application code to keep periods disjoint.
- Keep "now" out of the data. Never store a computed
is_currentflag that must be maintained; derive current fromvalid_to IS NULL AND superseded_at IS NULL.
Review checklist
- Facts whose past values matter carry
valid_from/valid_to, not a mutable column - Overlapping validity periods are prevented by constraint, not convention
- Required-fact gaps in the timeline are detected
- Corrections are distinguished from genuine changes in the model
- Where past decisions must be defensible, transaction time is stored alongside valid time
- Point-in-time queries pin both axes, not just valid time
- Historical rows are never updated in place; corrections supersede
- Temporal queries are indexed on key plus time range
- Interval bound convention (half-open) is consistent and documented
- Current state is derived, not stored as a maintained flag
Synthesized from TechCraft's Data Modeling Patterns series, parts P5–P6 (collection). The insurance date-of-birth scenario and the "a system runs on what it knew, not on objective truth" framing are TechCraft's; the review structure and checklist are this repository's.