Roadmap To Be A Data Engineer / Lesson 09
The Report That Changed Its Mind
Last June's report changes overnight. Keeping history in dimensions.
Sequel to Lesson 05 (the star schema — this is what happens to those dimensions over time) and to Lesson 08 (a re-coded enum is a dimension attribute changing with nobody recording when). Lessons 06–08 were about data arriving wrong. This one is about data that was right, staying right, and the description around it moving.
1The scene
Thursday 20 August, 09:41. Nordhavn — one of six brands we hold a commission agreement with — emails our partner manager:
“Your July statement bills us €6,723.02 commission for June. The partner dashboard you gave us access to says €5,322.44 for the same month. Which one do we pay?”
Same warehouse, same table, same SQL. One run 3 July, one run 20 August. In between, on 12 August at 11:20, the category team re-tiered two brands in the admin: Nordhavn Premium → Standard, Bergfeld Value → Standard. Real, approved, effective forward.
dim_brand is a full nightly reload, so the loader ran the equivalent of
UPDATE dim_brand SET tier = 'Standard' WHERE brand_id = 1. That is Type 1: the row
holds the current value and the prior value is not archived anywhere. Because every fact
joins dim_brand on brand_id at query time, that one UPDATE didn’t change August —
it changed every month we have ever recorded.
2What broke (June 2026, 1,405 orders, fact table untouched)
| Brand | June tier | Invoiced 3 Jul | Re-run 20 Aug | Delta |
|---|---|---|---|---|
| Nordhavn | Premium → Standard | 6,723.02 | 5,322.44 | −1,400.58 |
| Marlowe & Fen | Premium | 4,188.59 | 4,188.59 | 0.00 |
| Kestrel | Standard | 4,045.15 | 4,045.15 | 0.00 |
| Öland | Standard | 3,371.99 | 3,371.99 | 0.00 |
| Bergfeld | Value → Standard | 1,526.82 | 1,933.92 | +407.10 |
| Ivy Row | Value | 1,116.43 | 1,116.43 | 0.00 |
| Total | 20,972.00 | 19,978.52 | −993.48 |
Gross reconciliation break €1,807.68; the net (−€993.48) hides it, because one brand’s loss is another’s gain. Rates: Premium 24%, Standard 19%, Value 15%.
A fact is an event: it happened, at a time, and it is finished. A dimension is a description: it is true right now, and “right now” keeps moving. Joining a finished event to a moving description is how last quarter changes.
3Type 2: give the row a lifespan
Stop updating, start appending. Each version gets a validity interval, a flag, and a
surrogate key (brand_sk) that identifies this version of this brand, distinct from
the natural key brand_id. Six brands, two changes → 8 rows.
| brand_sk | brand_id | brand_name | tier | valid_from | valid_to | is_current |
|---|---|---|---|---|---|---|
| 1 | 1 | Nordhavn | Premium | 2026-06-01 | 2026-08-12 | false |
| 2 | 1 | Nordhavn | Standard | 2026-08-12 | 9999-12-31 | true |
| 7 | 5 | Bergfeld | Value | 2026-06-01 | 2026-08-12 | false |
| 8 | 5 | Bergfeld | Standard | 2026-08-12 | 9999-12-31 | true |
brand_id is no longer unique. That is the point.
Three load-bearing details:
- Half-open intervals
valid_from <= d < valid_to. Old row ends on the 12th, new row starts on the 12th. Closing atrun_date − 1invents a rule about “a day” that breaks the first time a change lands at 11:20. Use>=and<, neverBETWEEN. - Far-future sentinel
9999-12-31, notNULL— keeps the comparison a plain range check instead of a special case every consumer must remember. is_currentis a convenience, not the truth. It is derivable from the dates; never let a load path set the flag without setting the dates.
4The load: detect, close, append
-- 1. close every open row whose tracked attributes changed
UPDATE dim_brand d SET valid_to = :run_date, is_current = false
FROM stg_brand s
WHERE d.brand_id = s.brand_id AND d.is_current
AND d.attr_hash <> s.attr_hash; -- md5(tier || name || …)
-- 2. append the new version
INSERT INTO dim_brand (brand_id, brand_name, tier, attr_hash,
valid_from, valid_to, is_current)
SELECT s.brand_id, s.brand_name, s.tier, s.attr_hash,
:run_date, DATE '9999-12-31', true
FROM stg_brand s
LEFT JOIN dim_brand d ON d.brand_id = s.brand_id AND d.is_current
WHERE d.brand_id IS NULL OR d.attr_hash <> s.attr_hash;
-- 3. rows present yesterday and absent today are NOT deleted:
-- close them and set a deleted flag. Deletes destroy history too.
The attribute hash turns “did anything change?” into one comparison and records which
columns are tracked. :run_date is Lesson 04’s logical run date, not CURRENT_DATE —
re-running the 12 Aug load on the 20th must still start the version on the 12th. A Type 2
loader that reads the wall clock is not idempotent, and its history bends on every backfill.
5Two correct joins, and one that lies
-- A · current row. WRONG for any past period.
JOIN dim_brand b ON b.brand_id = f.brand_id
-- B · as-of join
JOIN dim_brand b ON b.brand_id = f.brand_id
AND f.sold_date >= b.valid_from AND f.sold_date < b.valid_to
-- C · surrogate key resolved once at load, frozen onto the fact
JOIN dim_brand b ON b.brand_sk = f.brand_sk
B and C give the same answer; C is the one to build. The as-of join is a range join — slower, easy to write wrong, and every query has to remember it. Resolving the version once in the loader turns every downstream query back into an equality join that is correct by construction. The analyst cannot get it wrong because there is nothing left to get wrong.
Same 1,405 June orders, three joins:
| June 2026 | Archived / as-of / by SK | Re-run, current row | Change |
|---|---|---|---|
| Premium | 45,465.04 | 17,452.24 | −61.6% |
| Standard | 39,037.38 | 77,228.73 | +97.8% |
| Value | 17,621.70 | 7,443.15 | −57.8% |
| Total GMV | 102,124.12 | 102,124.12 | 0.00% |
Every bucket moves by more than half and the total is identical to the cent. Revenue monitors, row counts and grand-total reconciliations all stay silent. Only the split moves, and nothing watches the split. (Nordhavn is 61.6% of June’s Premium GMV — with six hundred brands instead of six, the same failure is a permanent smear rather than a visible jump.)
6The honest check: was the real change visible?
Series rule: compute before claiming invisibility. Here Type 1 fails in both directions, and the second failure is worse. Weekly Premium GMV, each series compared against its own min–max band over the ten weeks before the re-tier:
| pre-cut band | post-cut weeks inside it | |
|---|---|---|
| Versioned join (Type 2) | €10,389 – €11,243 | 0 / 2 — the event is unmissable |
| Current-row join (Type 1) | €3,735 – €4,430 | 2 / 2 — the event is invisible |
Under Type 1, Premium GMV is €3,735–€4,430 every week of the quarter, before and after, because the Premium bucket has only ever contained the brands that are Premium today. A top brand left the tier and the tier report did not move by a euro.
Type 1 does not hide a change. It destroys the evidence that there was anything to compare against — then reports past and present as if they had always been the same.
7Six types, and the two that matter
| Type | On change | Past reports | Use it for |
|---|---|---|---|
| 0 retain | reject the change | stable | true immutables — original list date |
| 1 overwrite | UPDATE; prior value discarded |
rewritten | corrections of things always wrong (misspelt name) |
| 2 new row | close old, append new | stable | anything a past number depends on: tier, segment, region |
| 3 previous column | add previous_tier |
partly stable | one-off migrations |
| 4 history table | current in dim, versions in a side table | stable | fast-moving attributes |
| 6 hybrid (1+2+3) | Type 2 rows + current_tier on each |
both | “by tier at the time” and “by tier now” |
The deciding question is not how often the value changes — it is why:
- always wrong, now fixed → Type 1 (no period existed when it was right)
- was right, now different → Type 2 (overwriting deletes a fact about the world)
So: if this value changes, should a report about last quarter change with it? For anything money, contracts or targets are computed from, the answer is always no.
Where Type 2 is the wrong answer
| Attribute | Rows |
|---|---|
dim_brand.tier (2 changes / 91 days) |
6 → 8 (1.33×) |
dim_seller.rating, recomputed nightly |
12,000 → 4,380,000 (365×) |
A nightly-changing attribute is not slowly changing. Three escapes, in order:
- Put it on the fact. We already store
commission_rate_at_saleonfct_sale— which is why the money owed is recoverable even though the tier was overwritten. Values agreed at event time belong to the event. - Mini-dimension. Band the volatile attributes into a small
dim_seller_profileof a few hundred combinations, referenced from the fact. - Periodic snapshot fact. One row per entity per month with attributes as they stood.
8The three tests a Type 2 table owes you
| Test | Fails when | Symptom |
|---|---|---|
exactly one is_current per natural key |
a re-run appends without closing | every fact row counted twice |
no gaps/overlaps: each valid_to = next valid_from |
close at run_date − 1, or out-of-order backfill |
a day joins to nothing, or to two versions |
valid_from < valid_to, open row ends at sentinel |
same-day double change | silently empty intervals |
Our 8 rows pass all four checks across six keys. Move Nordhavn’s valid_to forward one day
and check 2 reports gap/overlap at 2026-08-13|2026-08-12 — a window where Nordhavn is both
Premium and Standard and every sale in it counts twice. These belong in the audit step of
Lesson 06’s Write–Audit–Publish, gating the swap in Lesson 07’s DAG. An untested Type 2
dimension is more dangerous than a Type 1 one, because it looks like history.
Late-arriving facts. A sale dated 10 Aug arriving on 15 Aug resolves to brand_sk = 1,
Premium, 24%. Against the current row it gets Standard, 19% — a €13,000 sale billed €650
short. Same rule as Lesson 04: decide everything from the event’s date.
9Ask your team
- Which dimension columns can change, and which are
UPDATEd in place today? (Ask for the loader, not the schema.) - If we re-ran last quarter’s board-deck queries this morning, would the numbers match the deck? Has anyone checked?
- For anything we bill or pay commission on — is the rate stored on the fact row at event time, or looked up from a dimension at query time?
- Do facts join on natural keys or on surrogate keys resolved at load?
- Does anything test our dimensions for exactly-one-current, gaps and overlaps?
- Lesson 08’s re-coded
item_conditionwas this same failure in other clothes. Which enum columns have been re-coded, and can we still read data from before the change?
10Hands on (~50 min)
- Reproduce. Run
seed.py. Confirm 4,303 sales, 1,405 June orders, invoice €20,972.00 vs re-run €19,978.52. No RNG — figures match to the cent. - Build both dimensions in SQLite/DuckDB. Write the June report three ways; assert B and C are byte-identical and A is not.
- Write the loader (detect–close–append, attribute hash,
:run_date). Run it twice for the same run date — if the dimension grows, it isn’t idempotent. - Write the three tests and prove each one fails.
- Break it on purpose: backfill 1 July with
CURRENT_DATEinstead of the logical date. - Now do sellers: a segment that changes for ~2% of sellers monthly. Show row growth, then re-solve with a mini-dimension.
Push as de-practice/09-scd-type-2. README: name one real dimension attribute at
Buddy & Selly that a past number depends on and that is overwritten today — and what the last
change to it did to last quarter’s report.
11Takeaway
Facts are immutable; dimensions are not; every warehouse assumes otherwise until a partner reads two different numbers for the same month. Give dimension rows a lifespan instead of a value, resolve the version once at load, and store the surrogate key on the fact so no downstream query can ask the wrong question.
Smallest useful action: take the one dimension attribute a bill, payout or target depends on
and stop it being overwritten — store the value on the fact at event time, or give the
dimension valid_from / valid_to. Ten minutes of loader, and last quarter stops moving.
12Vocabulary
- Slowly changing dimension (SCD) — a descriptive attribute that changes occasionally and unpredictably.
- Type 1 / Type 2 — overwrite the row vs close it and append a version.
- Natural key vs surrogate key —
brand_ididentifies the brand;brand_skidentifies one version of it. - Validity interval —
[valid_from, valid_to), half-open, far-future sentinel on the open row. - As-of join — resolving a dimension version by the event’s date rather than by “now”.
- Attribute hash — hash over the tracked columns; the cheap change test and a record of what is tracked.
- Late-arriving fact — an event reaching the warehouse after the dimension moved on.
- Mini-dimension — volatile attributes banded into their own small dimension.
- Periodic snapshot fact — one row per entity per period, attributes as they stood.
13Appendix — seed.py
Show the full code (169 lines)
"""
Lesson 09 - Slowly changing dimensions (SCD Type 2).
Deterministic: no random, no seed, modular arithmetic only.
Every figure in the lesson is produced by this file.
"""
from datetime import date, timedelta
import json
DAY0, DAYN = date(2026, 6, 1), date(2026, 8, 30) # 91 days
NDAYS = (DAYN - DAY0).days + 1
RETIER = date(2026, 8, 12) # category team re-tiers
FAR = date(9999, 12, 31)
# id, name, tier before 12 Aug, tier from 12 Aug, base price (cents), mix weight
BRANDS = [
(1, "Nordhavn", "Premium", "Standard", 12800, 4), # demoted
(2, "Marlowe & Fen", "Premium", "Premium", 10400, 3),
(3, "Kestrel", "Standard", "Standard", 7600, 5),
(4, "Öland", "Standard", "Standard", 6200, 5),
(5, "Bergfeld", "Value", "Standard", 4500, 4), # promoted
(6, "Ivy Row", "Value", "Value", 3300, 4),
]
RATE = {"Premium": 0.24, "Standard": 0.19, "Value": 0.15}
TIERS = ["Premium", "Standard", "Value"]
DOW = {0:.92, 1:.90, 2:.94, 3:1.00, 4:1.06, 5:1.24, 6:1.14} # Mon..Sun
# ---- the dimension, both ways -------------------------------------------------
dim_t1 = {b[0]: {"name": b[1], "tier": b[3]} for b in BRANDS} # Type 1: overwritten
dim_t2, sk = [], 0 # Type 2: [vf, vt)
for bid, name, before, after, _, _ in BRANDS:
if before == after:
sk += 1; dim_t2.append(dict(sk=sk, bid=bid, name=name, tier=before,
vf=DAY0, vt=FAR, cur=True))
else:
sk += 1; dim_t2.append(dict(sk=sk, bid=bid, name=name, tier=before,
vf=DAY0, vt=RETIER, cur=False))
sk += 1; dim_t2.append(dict(sk=sk, bid=bid, name=name, tier=after,
vf=RETIER, vt=FAR, cur=True))
def asof(bid, d):
for r in dim_t2:
if r["bid"] == bid and r["vf"] <= d < r["vt"]:
return r
raise KeyError((bid, d))
# ---- the fact -----------------------------------------------------------------
mix = [b[0] for b in BRANDS for _ in range(b[5])] # length 25
base = {b[0]: b[4] for b in BRANDS}
sales, cursor = [], 0
for d in range(NDAYS):
day = DAY0 + timedelta(days=d)
wobble = 1 + ((d * 37) % 17 - 8) / 100 # +/- 8%, deterministic
for _ in range(round(46 * DOW[day.weekday()] * wobble)):
cursor += 1
bid = mix[(cursor * 7 + (d * d) % 23 + (d * 5) % 7) % len(mix)]
price = base[bid] * (74 + (cursor * 29) % 53) // 100 # 74%..126%
ver = asof(bid, day) # resolved AT LOAD TIME
sales.append(dict(day=day, bid=bid, price=price, brand_sk=ver["sk"],
rate_at_sale=RATE[ver["tier"]],
commission=round(price * RATE[ver["tier"]])))
JUN = [s for s in sales if s["day"].month == 6]
E = lambda c: c / 100
# ---- three ways to answer "June GMV & commission by tier" ---------------------
def rollup(rows, tier_of):
out = {t: {"gmv": 0, "comm": 0, "n": 0} for t in TIERS}
for s in rows:
t = tier_of(s)
out[t]["gmv"] += s["price"]; out[t]["comm"] += s["commission"]; out[t]["n"] += 1
return out
sk_tier = {r["sk"]: r["tier"] for r in dim_t2}
A = rollup(JUN, lambda s: dim_t1[s["bid"]]["tier"]) # live join, Type 1
B = rollup(JUN, lambda s: asof(s["bid"], s["day"])["tier"]) # as-of join, Type 2
C = rollup(JUN, lambda s: sk_tier[s["brand_sk"]]) # frozen surrogate key
print(f"sales {len(sales):,} {DAY0}..{DAYN} June orders {len(JUN):,}")
print(f"dim_brand rows: Type 1 = {len(BRANDS)} Type 2 = {len(dim_t2)}\n")
print("JUNE by tier orders GMV EUR commission EUR")
for label, R in (("A live join (Type 1)", A), ("B as-of join (Type 2)", B),
("C surrogate key ", C)):
print(f" {label}")
for t in TIERS:
print(f" {t:<9} {R[t]['n']:6,} {E(R[t]['gmv']):14,.2f} {E(R[t]['comm']):14,.2f}")
tot = lambda R: sum(R[t]["gmv"] for t in TIERS)
print(f"\n B == C ? {B == C} A == B ? {A == B}")
print(f" total June GMV, all three joins: "
f"{E(tot(A)):,.2f} / {E(tot(B)):,.2f} / {E(tot(C)):,.2f} <- a totals monitor never fires")
for t in TIERS:
print(f" {t:<9} June archived {E(B[t]['gmv']):11,.2f} -> rerun {E(A[t]['gmv']):11,.2f}"
f" {A[t]['gmv']/B[t]['gmv']-1:+7.1%}")
prem = sum(s["price"] for s in JUN if asof(s["bid"], s["day"])["tier"] == "Premium")
print(f" Nordhavn = {sum(s['price'] for s in JUN if s['bid']==1)/prem:.1%} of June Premium GMV")
# ---- the invoice break --------------------------------------------------------
inv, rec = {}, {}
for s in JUN:
inv[s["bid"]] = inv.get(s["bid"], 0) + s["commission"]
rec[s["bid"]] = rec.get(s["bid"], 0) + round(s["price"] * RATE[dim_t1[s["bid"]]["tier"]])
print("\nJUNE COMMISSION per brand invoiced (Jul) recomputed (Aug) delta")
for bid, name, *_ in BRANDS:
print(f" {name:<14} {E(inv[bid]):18,.2f} {E(rec[bid]):18,.2f} {E(rec[bid]-inv[bid]):+13,.2f}")
gross = sum(abs(rec[b]-inv[b]) for b in inv)
print(f" {'TOTAL':<14} {E(sum(inv.values())):18,.2f} {E(sum(rec.values())):18,.2f} "
f"{E(sum(rec.values())-sum(inv.values())):+13,.2f}"
f" ({sum(rec.values())/sum(inv.values())-1:+.2%})")
print(f" gross reconciliation break (sum of |delta|): EUR {E(gross):,.2f}")
# ---- was the real event visible? ----------------------------------------------
def weekly(tier_of, tier="Premium"):
out = {}
for s in sales:
wk = s["day"] - timedelta(days=s["day"].weekday())
out[wk] = out.get(wk, 0) + (s["price"] if tier_of(s) == tier else 0)
return dict(sorted(out.items()))
W_true = weekly(lambda s: asof(s["bid"], s["day"])["tier"])
W_t1 = weekly(lambda s: dim_t1[s["bid"]]["tier"])
wks = list(W_true)
cut = next(i for i, w in enumerate(wks) if w >= RETIER) # first fully-post week
pre = wks[:cut-1]; post = wks[cut:]
print("\nweekly PREMIUM GMV as-of join (Type 2) live join (Type 1)")
for w in wks:
mark = " <- re-tier week" if w <= RETIER < w + timedelta(days=7) else ""
print(f" w/c {w} {E(W_true[w]):15,.0f} {E(W_t1[w]):20,.0f}{mark}")
for name, W in (("as-of", W_true), ("live ", W_t1)):
band = (min(W[w] for w in pre), max(W[w] for w in pre))
inside = sum(1 for w in post if band[0] <= W[w] <= band[1])
print(f" {name}: pre-cut band [EUR {E(band[0]):,.0f} .. EUR {E(band[1]):,.0f}] "
f"-> {inside}/{len(post)} post weeks inside the band")
# ---- SCD2 integrity checks ----------------------------------------------------
def scd2_checks(rows):
bad, bykey = [], {}
for r in rows: bykey.setdefault(r["bid"], []).append(r)
for bid, rs in bykey.items():
rs = sorted(rs, key=lambda r: r["vf"])
if sum(1 for r in rs if r["cur"]) != 1: bad.append((bid, "not exactly one current row"))
if rs[-1]["vt"] != FAR: bad.append((bid, "open row not far-future"))
for r in rs:
if not r["vf"] < r["vt"]: bad.append((bid, "empty interval"))
for a, b in zip(rs, rs[1:]):
if a["vt"] != b["vf"]: bad.append((bid, f"gap/overlap at {a['vt']}|{b['vf']}"))
return bad
print("\nSCD2 integrity:", scd2_checks(dim_t2) or "clean (4 checks x 6 natural keys)")
bad = [dict(r) for r in dim_t2]; bad[0] = {**bad[0], "vt": date(2026, 8, 13)} # 1-day overlap
print(" with a 1-day overlap injected:", scd2_checks(bad))
# ---- late-arriving fact -------------------------------------------------------
v = asof(1, date(2026, 8, 10))
print(f"\nlate fact: sale dated 2026-08-10, loaded 2026-08-15 -> sk {v['sk']}, {v['tier']}, "
f"{RATE[v['tier']]:.0%} (a current-row join would say "
f"{dim_t1[1]['tier']}, {RATE[dim_t1[1]['tier']]:.0%})")
# ---- when NOT to use Type 2 ---------------------------------------------------
for n, per_day, days in (("dim_brand.tier", 0, 0),):
pass
print(f"\nrow growth: dim_brand.tier {len(BRANDS)} -> {len(dim_t2)} rows "
f"({len(dim_t2)/len(BRANDS):.2f}x, 2 changes in 91 days)")
S, D = 12_000, 365
print(f" dim_seller.rating (recomputed nightly) {S:,} -> {S*D:,} rows ({D}x) "
f"-- do NOT Type-2 this")
json.dump({"A": A, "B": B, "C": C,
"weekly": {str(w): [W_true[w], W_t1[w]] for w in wks},
"inv": inv, "rec": rec,
"dim_t2": [{**r, "vf": r["vf"].isoformat(), "vt": r["vt"].isoformat()} for r in dim_t2]},
open("/home/claude/l09/out.json", "w"), indent=1)