Join historical sales against a tax-rate table that changed from 8% to 10% on day 100. A naive join reads the table's current rate, so it overcharges old sales — reporting $100.00 where the correct answer is $94.00 — and re-running the same sales on a different day gives $80.00, a $20.00 drift. A point-in-time join pins each sale to the rate in effect at sale time and reports $94.00 on every run.
Join a stream of historical sales against a tax-rate table that changed over time — 8% before day 100, 10% from day 100 on. A naive stream-table join looks up the table's current state at processing time, so when you reprocess last year's sales it applies this year's rate: old sales are overcharged, and the total comes out $100.00 when the point-in-time-correct answer is $94.00. Worse, the naive job isn't even stable: re-run the identical sales on a day when the current rate was still 8% and it reports $80.00 — same input, same code, a $20.00 drift, purely because of when you ran it. Pin each sale to the rate in effect at sale time (a slowly-changing-dimension lookup) and the total is $94.00 whether you run it today or next year: correct, and deterministic on replay.
Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 12 — Stream Processing, §"Time dependence of joins" (stream-table joins over slowly-changing dimensions). Paraphrasing the claim under test:
When the table you join against changes over time, the join result depends on which version of the table row you use — and in a data warehouse this versioned dimension is known as a slowly changing dimension (SCD), addressed by giving each version its own identifier.
The book's point is that a stream-table join is only well-defined once you decide which version of the table row a given event should see. Look up the current row and the join silently depends on wall-clock time; look up the version that was in effect at the event's timestamp and it becomes a pure function of the data. Here you watch both.
The surprise is that a join can quietly read the clock as an input. Each line notes where it shows up; a free resource follows.
naive_join / pointintime_join looking a rate up per sale.TAX_TABLE list of (effective_from, rate) versions.rate_as_of(sale.day) vs current_rate(run_day), and is the entire difference between $94 and the drifting $80/$100.A stream-table join enriches each event by reading a row from a table. The only question is which version of the row it reads.
Same sales, same table, same two-line lookup — the difference is rate_as_of(day) versus current_rate(run_day). The whole thing is one script, code/stream_table_time_travel.py.
cd study/ddia/ch12/code
python3 stream_table_time_travel.py
Do not read the output yet — make each prediction first. The table changed from 8% to 10% on day 100; the four sales fall on days 40, 80, 120, 160.
Predict. The job runs today (day 300), when the current tax rate is 10%. It joins each of the four sales — $100, $200, $300, $400 — against the table's current rate. What total tax does the naive join report?
$100.00. Every sale — including the two from before the rate change — got taxed at today's 10%. The two old sales ($100 and $200) are overcharged: they happened while the rate was 8%.
Predict. Now re-run the exact same job on the exact same sales, but on day 90 — a day when the current rate was still 8% (the change to 10% hasn't happened yet). Same code, same input. Will the total match Step 1's $100.00, or change? To what?
$80.00 — a $20.00 drift from Step 1. Nothing about the sales changed; the only difference is the calendar day the job ran. The naive join reads the table's current state, and "current" moved. The job is not a function of its inputs — it smuggles in the wall clock.
Predict. Fix it: match each sale to the rate that was in effect at that sale's day — 8% for the day-40 and day-80 sales, 10% for the day-120 and day-160 sales. What total does this report, and will it change between a run on day 90 and a run on day 300?
$94.00, on both run days. The two old sales use 8%, the two new ones use 10% — each sale sees the rate that was real when it happened. The run day is passed in and ignored, so replay is exact.
Reprocessing — the whole promise of keeping events in a log — is only correct if the job is a deterministic function of its inputs: feed the same events in and you must get the same output out, whenever you run it. A stream-table join reads two things, the event and a table row, and multiplies them. The event is immutable — the sale for $100 on day 40 is fixed forever. The table row is not: the tax rate has versions. So the join's determinism hinges entirely on which version it reads.
The naive join reads the current version — the row as it stands at the instant the job runs. That instant is not part of the input; it's the wall clock. So the naive join is really a function of (events, run-time), and run-time is a hidden input the events never carried. Change the run day and the output changes: $100 today, $80 last year. Same sales, different answer — the definition of non-deterministic replay. The overcharge and the drift are the same bug seen two ways: applying a later version of the row to an earlier event.
The point-in-time join removes the hidden input. Instead of "the current rate" it asks "the rate in effect on the sale's own day" — rate_as_of(sale.day). The sale already carries its day, so the lookup reads nothing but the input: it's a pure function again. Every replay, on any run day, retraces the same versions and lands on $94. That is the SCD fix in one line: don't look up the dimension's present, look up the dimension's history at the event's timestamp — equivalently, denormalize the effective rate (or its version id) into the event at capture time so the join has nothing time-varying left to read.
current_rate and rate_as_of would return the same number and both joins would agree — a constant dimension makes current-state and point-in-time identical, and there is nothing to drift. Equally, if you only ever processed each event once, at the moment it arrived, "current" would coincide with "at event time" and you'd never notice. The anomaly needs both ingredients: a dimension that changes and events processed (or reprocessed) at a different time than they occurred. That combination is exactly what an event log invites — so this is a hazard you take on the moment you start replaying.
rate_as_of(day) onto that API and see that this exercise's fix is a first-class feature there.recorded_on axis to the table and watch a backdated rate correction reprocess correctly.Sources: DDIA 2e, Ch. 12, §"Time dependence of joins", §"Stream joins", §"Reprocessing data" · Kimball, "Slowly Changing Dimensions" (Type 2).