THIS EXPLANATION
THE ROOM
CDA·160 Computing, Data & AI 6 MIN · 8 STATIONS

Point-in-time joins

A Socratic walk-through of point-in-time joins — reasoned out one step at a time, not lectured.

abcdefgh
a

The question we started with

THE QUESTION #

How do you match a record to the version of the truth that was current when it happened?

A join is normally a matter of equality. This order has customer 88213; find the customer row whose key is 88213; done. There is one match, and asking for it is the simplest thing in the language.

Now suppose the customer table holds four rows for that customer, one per era of their details. The equality still succeeds — four times. Which of the four is the right one? And notice that the question has no answer at all until you say when: the right row for an order placed in 2019 is not the right row for one placed last week. The join has stopped being about identity and become about identity at a moment.

b

Reasoning it through

REASONING #

Begin by writing down what each version row actually claims. It does not claim "this customer is in the South". It claims "from 15 March 2021 until further notice, this customer is in the South". Each row is an assertion with a window attached. If we make that window explicit as two columns — when it began, when it ceased — then the matching rule almost writes itself: take the row whose window contains the moment of the event.

So the join gains a condition. Match on the entity, and then require that the event's timestamp falls inside the version's interval. That is the whole of a point-in-time join — an equality plus a containment.

Which sounds trivial until you ask how the endpoints behave. Suppose one version ends on 15 March and the next begins on 15 March, and both intervals include their endpoints. An event on 15 March now matches both, and the join produces two rows where the data contains one order. Every downstream total is silently doubled. So the intervals must partition time rather than merely cover it, and the standard way is a half-open convention: the start moment belongs to the row, the end moment belongs to the next one. That single convention, applied everywhere, is what makes exactly one row match. It is a small rule with a large blast radius when it is applied inconsistently across tables.

There is a second endpoint problem. What is the end date of the version that is still current? Leaving it empty is honest, but the containment test must then special-case null in every query. The common alternative is a far-future sentinel date, keeping the test uniform at the cost of a fictional date in the data. Neither is obviously right; what matters is consistency, because a mixture guarantees rows that silently fail to match.

Now the harder case, and the reason this technique earns a name of its own. Suppose the event arrives late — an order from June, loaded in September. The instinct is to join it to the customer as they are, because that row is right there and flagged current. But the order happened in June, and joining to today's row rewrites June's history using September's facts. So the join must use the event's time, never the load time and never now. This is the discipline machine-learning feature stores enforce, where a value not yet knowable at the event's timestamp leaks the future into training data and yields a model that tests well and fails in production.

And here a genuine complication rather than a tidy resolution. Two notions of time are in play and they can disagree: when the fact was true in the world — the customer moved in March — and when the system came to know it, in May. Store only the first, and a backdated correction rewrites what your reports say about March, which is right for analysis and wrong for an audit that must reproduce what was reported at the time. Storing both — a bitemporal model — lets you ask either question, but every query then carries two interval conditions. Plenty of systems reasonably decide they need only one.

c

The analogy

THE ANALOGY #
THE FIGURE

Think of reading a newspaper archive to settle what a share was worth on a given morning. You do not want the latest edition, however accurate it now is; you want the edition that was on the stands that day, because that is the one whose price applies to the trade you are examining. Each edition carries its date, and the date is what makes the archive answerable — without it you would have a pile of contradictory prices and no way to choose between them.

WHERE IT BREAKS DOWN

a newspaper is fixed once printed, whereas a database row can be corrected after the fact, so a data archive has to record not only the day the fact applied to but the day it was written down — a distinction the newspaper never has to make.

d

Clarifying the model

THE MODEL #

Three refinements.

First, the misconception that a point-in-time join is a performance problem to be optimised away. It is a correctness rule. A join to the current row is not a faster version of the same answer; it is a different and usually wrong answer, and it is wrong in the direction that flatters — reports look tidier and models score better, because both have been handed information that did not exist yet.

Second, this technique depends entirely on the versioning underneath it. If the description table was updated in place, no interval exists and no point-in-time join is possible — the past is simply unavailable, and no query can recover it. Versioning creates the material; the join is what reads it correctly.

Third, in warehouses the join is often done once rather than at read time: as the fact is loaded, the correct version's key is resolved and stored on the fact row, so later queries are plain equality joins. That is the same rule applied earlier, and it moves the risk rather than removing it — get the resolution wrong at load, and every later query inherits the error invisibly.

e

A picture of it

THE PICTURE #
Point-in-time joins
Point-in-time joins the boxes carrying an id and a risk are the properties a point-in-time join must deliver; the plain boxes are the mechanisms that deliver them. Follow each arrow from a mechanism to what it buys. The half-open convention exists solely to stop an event matching two adjoining versions, while the predicate on the event's own timestamp does double duty, keeping later knowledge out and making old reports reproducible. The two time columns hang off the third requirement alone, which is why many systems live without them. {"generator":"mermaid-svg-renderer@3.2.1","source":"../Socrates/.diagram-cache/_src/point-in-time-joins.md","sourceIndex":1,"sourceLine":4,"sourceHash":"ee05a6f2c5824883f9ea312c73f91175c160c3521f1408b8944982d004df3109","diagramType":"requirement","layoutVariant":"source","repairedDuplicateIds":[],"motion":"entrance-with-reduced-motion-fallback","presentation":"editorial","attempt":1,"viewBox":{"x":0,"y":0,"width":1519,"height":538},"qa":{"passed":true,"findings":[]}} satisfies satisfies satisfies satisfies <<Requirement>> exactly_one_match ID: 1 Text: every event matches one version row and no more Risk: High Verification: Test <<Requirement>> no_future_information ID: 2 Text: no value first known after the event may be used Risk: High Verification: Analysis <<Requirement>> reproducible_history ID: 3 Text: rerunning last year's report returns last year's totals Risk: Medium Verification: Test <<Element>> half_open_intervals Type: convention <<Element>> event_time_predicate Type: join condition <<Element>> two_time_columns Type: model choice

How to readthe boxes carrying an id and a risk are the properties a point-in-time join must deliver; the plain boxes are the mechanisms that deliver them. Follow each arrow from a mechanism to what it buys. The half-open convention exists solely to stop an event matching two adjoining versions, while the predicate on the event's own timestamp does double duty, keeping later knowledge out and making old reports reproducible. The two time columns hang off the third requirement alone, which is why many systems live without them.

f

What became clearer

WHAT CLEARED #
WHAT CLEARED

Once a description is kept as a sequence of versions, equality is not enough to identify anything — it identifies the entity, but an entity has had several sets of details. Adding the event's own timestamp as a containment test picks out the version that was in force, and that single condition separates a report that reproduces from one that quietly rewrites itself. The care lies in the edges: half-open intervals so nothing matches twice, a uniform treatment of the open-ended current row, the event's time rather than the load time, and knowing whether the question is what was true then or what was known then.

g

Where to go next

ONWARD #
  • What a bitemporal model costs in practice, and when only one time dimension is genuinely needed.
  • Why feature stores treat this join as the main defence against leaking future information into a model.
  • Whether resolving the version key at load time is worth the loss of the ability to re-derive it.
h

Key terms

TERMS #
TermWhat it means
Point-in-time joina join that matches a record to the version of an entity whose validity interval contains the record's timestamp.
Validity intervalthe pair of stamps marking when a version's claims began and ceased to apply.
Half-open intervala convention in which the start instant belongs to a version and the end instant belongs to the next, so intervals partition time.
Bitemporal modela design storing both when a fact was true and when the system recorded it.
Data leakageusing information unavailable at the moment being predicted.

Every term the collection defines is gathered in the glossary.

Nearby on the shelf

4