Roadmap To Be A Data Engineer / Lesson 09

Lesson 09 SCD Type 2 Fundamentals §4 Modelling About 20 min read

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 at run_date − 1 invents a rule about “a day” that breaks the first time a change lands at 11:20. Use >= and <, never BETWEEN.
  • Far-future sentinel 9999-12-31, not NULL — keeps the comparison a plain range check instead of a special case every consumer must remember.
  • is_current is 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:

  1. Put it on the fact. We already store commission_rate_at_sale on fct_sale — which is why the money owed is recoverable even though the tier was overwritten. Values agreed at event time belong to the event.
  2. Mini-dimension. Band the volatile attributes into a small dim_seller_profile of a few hundred combinations, referenced from the fact.
  3. 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

  1. Which dimension columns can change, and which are UPDATEd in place today? (Ask for the loader, not the schema.)
  2. If we re-ran last quarter’s board-deck queries this morning, would the numbers match the deck? Has anyone checked?
  3. 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?
  4. Do facts join on natural keys or on surrogate keys resolved at load?
  5. Does anything test our dimensions for exactly-one-current, gaps and overlaps?
  6. Lesson 08’s re-coded item_condition was 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)

  1. 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.
  2. Build both dimensions in SQLite/DuckDB. Write the June report three ways; assert B and C are byte-identical and A is not.
  3. 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.
  4. Write the three tests and prove each one fails.
  5. Break it on purpose: backfill 1 July with CURRENT_DATE instead of the logical date.
  6. 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_id identifies the brand; brand_sk identifies 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)
Back to top