Skip to content

Temporal Knowledge: Facts That Were True Then

11 min read · updated August 4, 2026

“Ines Karlsson is CTO of Vandenberg” is not a fact. It is a fact with a start date, probably an end date, and a separate date on which your system came to believe it. Graphs that store only the current state cannot answer any question about the past, and the questions people actually have are nearly all about the past.

Two time axes, not one

AxisDescription
valid timeWhen the fact was true in the world. Karlsson became CTO on 2021-04-01 and left on 2024-09-30. This is a property of reality and it can be edited retroactively when you learn you had it wrong.
transaction timeWhen your database believed it. The row was inserted on 2021-05-17 because that is when the press release was ingested. This is append-only and is never edited, because rewriting what you knew is what makes an audit impossible.

A store that keeps both is called bitemporal, and it answers a question nothing else can: what did we believe on the day we made that decision? That is the question asked in every regulatory investigation and every post-incident review, and it is unanswerable in a graph that has been updated in place.

Most systems need valid time. Add transaction time when a decision made from the graph has to be defensible later — pricing, credit, eligibility, anything a regulator reads. Adding it later is a migration you do not want.

Three ways to store an interval

1. Properties on the edge (property graph)

MATCH (p:Person {id: 'pe_01J9...'}), (c:Company {id: 'co_01J9...'})
MERGE (p)-[r:HOLDS_ROLE {title: 'CTO', valid_from: date('2021-04-01')}]->(c)
SET r.valid_to    = date('2024-09-30'),
    r.recorded_at = datetime()

Simplest, and it works until the same person holds the same role at the same company twice with a gap in between — at which point the edge key has to include valid_from, and every query has to know that.

2. An event node

(p:Person)-[:HELD]->(e:Employment {
    title: 'CTO', valid_from: date('2021-04-01'), valid_to: date('2024-09-30')
})-[:AT]->(c:Company)

More verbose and strictly better once the relationship has any substance to it: the employment can carry a salary band, a source, a confidence, and a second person who approved it. This is the same n-ary-relation move described in what a knowledge graph is, and temporality is the most common reason to reach for it.

3. Qualified statements (RDF)

RDF has no place to put a property on a triple, so it reifies. Wikidata uses this pattern extensively, with the statement node carrying qualifiers for start and end:

ex:karlsson p:P39 ex:statement1 .

ex:statement1
  ps:P39  ex:cto ;                       # the value: position held
  pq:P580 "2021-04-01"^^xsd:date ;       # qualifier: start time
  pq:P582 "2024-09-30"^^xsd:date ;       # qualifier: end time
  pq:P642 ex:vandenberg ;                # qualifier: of
  prov:wasDerivedFrom ex:pressRelease44 .

The cost is that every temporal query has to go through the statement node rather than the shortcut predicate. This is precisely the wdt: versus p:/ps:/pq: distinction that catches people out on Wikidata: the shortcut gives the current best value and silently discards the history.

Half-open intervals, and the off-by-one day

Store intervals as [valid_from, valid_to) — inclusive start, exclusive end. Closed intervals produce the bug where a role ends on 2024-09-30 and the next starts on 2024-09-30, and every “who held this role on that day” query returns two people. With half-open intervals, adjacency and non-overlap are the same condition, and the test for a point in time is one expression with no special cases:

valid_from <= t AND (valid_to IS NULL OR t < valid_to)

On the open end, choose one convention and enforce it. NULL meaning “still true” is honest but forces the IS NULL branch into every predicate and every index. A sentinel such as 9999-12-31 keeps queries and range indexes simple and lies slightly. Both work; mixing them does not, and mixed conventions in one table is the most common cause of a temporal query that is almost right.

The four question shapes

As-of: what was true on a date

MATCH (p:Person)-[:HELD]->(e:Employment)-[:AT]->(c:Company {id: $company})
WHERE e.valid_from <= $asOf
  AND (e.valid_to IS NULL OR $asOf < e.valid_to)
RETURN p.name, e.title

The version that matters more, and that only a bitemporal store can answer, adds the second axis — what we believed on that date about that date:

SELECT person_id, title
FROM employment
WHERE company_id  = $company
  AND valid_from <= $as_of_valid
  AND (valid_to IS NULL OR $as_of_valid < valid_to)
  AND recorded_at <= $as_of_known
  AND (superseded_at IS NULL OR $as_of_known < superseded_at);

Overlap: were two things true at the same time

Two intervals overlap if each starts before the other ends. The symmetric form is the one to memorise, because the naive version misses the containment case:

a.valid_from < b.valid_to AND b.valid_from < a.valid_to
-- who was at the company at the same time as Karlsson?
SELECT DISTINCT b.person_id
FROM employment a
JOIN employment b
  ON a.company_id = b.company_id
 AND a.person_id <> b.person_id
 AND a.valid_from < coalesce(b.valid_to, 'infinity'::date)
 AND b.valid_from < coalesce(a.valid_to, 'infinity'::date)
WHERE a.person_id = $karlsson;

Sequence: what happened before what

“Was the supplier relationship established before or after the acquisition?” is a comparison of two valid_from values, and it is the question that turns a graph into evidence. It is also the one that most exposes missing dates: a fact with no start date cannot be ordered, so make the date required at the point of extraction rather than discovering the gap during an investigation.

Duration and churn

MATCH (:Company {id: $company})<-[:AT]-(e:Employment)
WHERE e.title = 'CTO'
RETURN e.valid_from,
       coalesce(e.valid_to, date()) AS ended,
       duration.inMonths(e.valid_from, coalesce(e.valid_to, date())).months AS months
ORDER BY e.valid_from
Temporal function names differ between databases more than almost anything else — duration arithmetic, date truncation and interval construction all have engine-specific spellings. Treat the shapes above as correct and check the function names against your engine.

Stopping overlaps at write time

Two employment rows for the same person, same company, same role, with overlapping validity, is a contradiction rather than two facts. Catch it at insert. PostgreSQL can do this declaratively with an exclusion constraint over a range type:

CREATE EXTENSION IF NOT EXISTS btree_gist;

ALTER TABLE employment
  ADD COLUMN validity daterange
    GENERATED ALWAYS AS (daterange(valid_from, valid_to, '[)')) STORED;

ALTER TABLE employment
  ADD CONSTRAINT no_overlapping_roles
  EXCLUDE USING gist (
    person_id  WITH =,
    company_id WITH =,
    title      WITH =,
    validity   WITH &&
  );

Graph databases generally have no equivalent, so the check becomes a query run before the write, or a scheduled validation that reports violations. The scheduled version is weaker but it is the honest option when writes come from several loaders, and it belongs next to the other checks in keeping a knowledge graph fresh.

Point-in-time consistency across several edges

The subtle failure of temporal graphs is not in one query; it is in a query that traverses several temporal edges and applies the as-of test to only some of them. “Who was the account manager for the customer that owned this contract in March 2024” touches three intervals — the contract’s ownership, the account’s assignment, and the employee’s employment — and every one of them must be evaluated at March 2024, not at today.

-- wrong: only the first hop is time-filtered.
-- Returns the CURRENT account manager for the customer who owned it then.
SELECT e.name
FROM contract_ownership co
JOIN account_assignment aa ON aa.customer_id = co.customer_id
JOIN employee e            ON e.id = aa.employee_id
WHERE co.contract_id = $1
  AND co.valid_from <= $t AND (co.valid_to IS NULL OR $t < co.valid_to);

-- right: the same instant applied to every temporal edge in the path
SELECT e.name
FROM contract_ownership co
JOIN account_assignment aa
  ON aa.customer_id = co.customer_id
 AND aa.valid_from <= $t AND (aa.valid_to IS NULL OR $t < aa.valid_to)
JOIN employment em
  ON em.employee_id = aa.employee_id
 AND em.valid_from <= $t AND (em.valid_to IS NULL OR $t < em.valid_to)
JOIN employee e ON e.id = aa.employee_id
WHERE co.contract_id = $1
  AND co.valid_from <= $t AND (co.valid_to IS NULL OR $t < co.valid_to);

The first query runs, returns a plausible name, and is wrong in a way nobody notices for a year. Two things reduce the risk: build one parameterised view per temporal relation that takes the instant, so that composing them is mechanical, and write a test that runs each multi-hop query at two different instants and asserts the answers differ where the underlying data says they should. A temporal query that returns the same answer for every date is almost always missing a predicate.

What it costs

  • Row count. A bitemporal table stores a new row per correction rather than an update. A field corrected three times is four rows. Plan for the table to be several times larger than the state it describes.
  • Every query gets two more predicates, and forgetting them returns superseded rows that look completely plausible. Wrap the current-state case in a view so that the default access path is correct and only the historical queries are hand-written.
  • Indexes must cover the interval, not just the keys. A range index (GiST over a range type in PostgreSQL, or a composite on (entity_id, valid_from) elsewhere) is what keeps as-of queries from scanning history.
  • Extraction has to produce dates. A temporal schema fed by an extractor that does not emit dates is a temporal schema full of nulls, which is worse than no temporal schema because the queries silently return partial answers.