learn-data-warehouse-with-phoebe / Builder session 5 of 10
Learn Data Warehouse with Phoebe · Builder track · Session 5 of 10

Slowly changing dimensions

Daybreak's star schema is up and answering questions - until Liam Ford upgrades from Basic to Pro and quietly breaks every historical report. Tonight you meet the most famous problem in dimensional modeling: what to do when a dimension row changes. You will run the classic Type 2 fix by hand, then automate it with MERGE, on a live warehouse in your browser.

🟠 Builder track Practitioners: analysts · DE · DS · PMs Runs in your browser · DuckDB 45 min
0-3 · Recap 3-20 · The lie & the SCD menu 20-42 · Build-along: Type 2 by hand + MERGE 42-45 · Q&A
Part 0

Where the build stands

Your Daybreak warehouse so far: a typed staging layer (b2) feeding a star schema (b3-b4) - fct_order_line surrounded by dim_date, dim_customer, and dim_product, joined on surrogate keys. Tonight adds a fourth table: dim_customer_scd, a history-keeping version of the customer dimension with valid_from, valid_to, and is_current columns. It starts loaded and current on every page below - your job is to change it correctly.

Live - presented in session Self-study - read after class ▶ Live warehouse - editable & runnable Official sources covered
★ What you walk out with today The four SCD types and when each is honest, the reason surrogate keys exist at all, and a Type 2 change you executed twice - once by hand (three moves) and once with a single MERGE statement - plus the point-in-time join that reads the history back correctly.
Part 1 · covers 365DS S7 slowly changing dimensions

The overwrite lie 7 min live

In July, Liam Ford (customer 2, Basic since December) upgrades to Pro. The lazy fix is one UPDATE on his dimension row. It feels harmless - until finance re-runs the Q1 revenue-by-plan report and gets different numbers for a quarter that ended months ago. Every order Liam placed as a Basic customer is now re-filed under Pro, because the report joins facts to whatever the dimension says today.

Q1 revenue by plan · run in April plan · revenue Basic · 320.00 (Liam's 42.00 in here) Pro · 610.00 Same report · re-run in July plan · revenue Basic · 278.00 (Liam's 42.00 gone) Pro · 652.00 (it moved here) one UPDATE What happened in between: UPDATE dim_customer SET plan = 'Pro' WHERE customer_id = 2; No fact row changed. No revenue moved in the real world. Only the label rewrote itself - and every join from old facts to the customer dimension now reads the new label. Illustrative numbers - Liam's two completed Q1 orders total 42.00 and swap columns. History that rewrites itself is a lie. A closed quarter must give the same answer forever.
🔍 Click to zoom - one innocent UPDATE, and a closed quarter changes its answer
LiveWhy nobody notices until it hurts3 min

The overwrite lie is sneaky because nothing errors. The query runs, the dashboard renders, the numbers look plausible. It surfaces weeks later as a trust incident: finance's saved Q1 deck disagrees with the live Q1 dashboard, and now every number in the warehouse is suspect.

  • Facts are safe by nature: an order happened on a date, at a price. Nobody edits history there.
  • Dimensions drift by nature: customers move cities, upgrade plans, change names. Products get repriced and recategorized. The change is real and legitimate - the question is how to record it.
  • "Slowly changing" is Kimball's name for exactly this: attributes that change occasionally, not constantly, and whose history may matter.
Real world

The sales-territory reorg. A company redraws sales regions in June. Overwrite the rep dimension and every January deal re-files into the new regions - last year's regional performance review is now fiction, and the bonus disputes write themselves. Territory realignment is the canonical SCD war story in almost every BI team.

Self-studyWhen overwriting is actually fine2 min read

Not every change deserves history. If a customer's name was misspelled at signup, the old value was never true - fixing it everywhere, including the past, is the honest move. The test question: "was the old value ever correct?" If no, overwrite freely (that is Type 1, next part). If yes, and anyone might slice facts by it, you owe the warehouse a history.

Part 2 · the Kimball SCD type menu

The SCD menu: Types 0, 1, 2, 3 8 min live

The fix is a menu, not a single trick. For each changing attribute you pick a policy - and the policies have numbers. Two of them do ninety-five percent of the work in real warehouses.

How each type records "Liam: Basic becomes Pro" Type 0 Liam · Basic (frozen forever) never change - signup_date, original plan, birth facts Type 1 Liam · Pro (old value gone) overwrite - honest for typo fixes, a lie for real change Type 2 key 2 · Basic · Dec 10 to Jun 30 key 16 · Pro · Jul 01 to (open) new ROW per version + valid_from / valid_to / is_current the workhorse - full history, point-in-time joins work Type 3 Liam · Pro · prev_plan: Basic previous-value COLUMN - one step of history, rare Pick per ATTRIBUTE, not per table: one dimension can hold Type 0, 1, and 2 columns side by side. Default instinct: Type 1 for corrections, Type 2 for anything reports slice by. 0 and 3 are seasoning.
🔍 Click to zoom - four policies for one change, and why Type 2 is the workhorse
LiveWhy surrogate keys make Type 2 possible4 min

Back in b4 you built dim_customer with a generated customer_key instead of joining facts straight to customer_id. Tonight is the payoff. Type 2 means one customer becomes many rows - and many rows cannot share a primary key.

  • Natural key (customer_id = 2): identifies the person. Stays the same across every version. Nicknamed the durable key.
  • Surrogate key (customer_key = 2, then 16): identifies one version of the person. Every new version gets a fresh key.
  • A fact row loaded in March points at the version key that was current in March - so old facts stay attached to old attribute values automatically, with a plain key join.

If your facts join on the natural key instead, history is still recoverable - but every query must do the date-range dance you will meet in Part 3. Both patterns exist in the wild; know which one your shop uses.

Self-studyChoosing a type: a 30-second checklist2 min read
  • Was the old value ever true? No: Type 1. Yes: keep going.
  • Will anyone slice facts by this attribute's past values? Yes: Type 2. No: Type 1 is defensible.
  • Is the attribute a birth fact (signup date, first channel)? Type 0 - freeze it.
  • Do analysts only ever ask "current vs immediately previous"? Type 3 fits, but ask twice - the second change destroys the first history.
Part 3 · reading a Type 2 dimension

Reading history back: current view & point-in-time 6 min live

A Type 2 dimension answers two different questions, and each has its own join. "What is true now?" filters WHERE is_current. "What was true when the fact happened?" matches the fact's date into the version's validity window: fact_date BETWEEN valid_from AND coalesce(valid_to, DATE '9999-12-31') - the coalesce turns the open-ended current row into a window that ends at the end of time.

LiveTour tonight's new table: dim_customer_scd4 min

The scd setup adds dim_customer_scd to your star: same attributes as dim_customer, plus the three history columns. Right now every customer has exactly one row, valid from their signup date, open-ended, current.

SELECT customer_id, name, plan, valid_from, valid_to, is_current
FROM dim_customer_scd
WHERE is_current
ORDER BY customer_id
LIMIT 6;
SELECT customer_id, count(*) AS versions
FROM dim_customer_scd
GROUP BY customer_id
ORDER BY versions DESC, customer_id
LIMIT 5;
Self-studyThe 9999-12-31 trick, and why not NULL comparisons2 min read

Current rows keep valid_to NULL - honest, because the window has no known end. But date BETWEEN x AND NULL evaluates to NULL, which silently drops rows. Hence the idiom: coalesce(valid_to, DATE '9999-12-31') converts "no end yet" into "the end of time" only at query time. Some teams store 9999-12-31 physically instead of NULL; either works, pick one and write it down in the model docs.

Demo 1 of 2

Run Liam's upgrade by hand ★ 12 min · everyone builds

A Type 2 change is three moves: close the old row (stamp valid_to, flip is_current off), insert the new version (fresh surrogate key, new value, open-ended), and read the timeline back. Do it slowly once by hand - the MERGE in Demo 2 will make far more sense afterwards.

Move 1 - close out. The UPDATE touches only Liam's current row: valid_to becomes June 30, is_current becomes FALSE. His Basic era is now a sealed window: Dec 10 to Jun 30.

Move 2 - insert the new version. A fresh surrogate key (max(customer_key) + 1), plan Pro, valid from July 1, no end date, current. Same person, second row.

Move 3 - read the timeline. The final SELECT shows both versions in order. Two rows, no gaps, no overlap: Basic ends June 30, Pro begins July 1.

Run it. Then edit: make the upgrade effective August 1 instead, and re-run. The timeline should reseal itself around the new dates.

UPDATE dim_customer_scd
SET valid_to = DATE '2026-06-30', is_current = FALSE
WHERE customer_id = 2 AND is_current;

INSERT INTO dim_customer_scd
SELECT (SELECT max(customer_key) + 1 FROM dim_customer_scd),
       customer_id, name, city, country,
       'Pro', DATE '2026-07-01', NULL, TRUE
FROM stg_customers
WHERE customer_id = 2;

SELECT customer_id, plan, valid_from, valid_to, is_current
FROM dim_customer_scd
WHERE customer_id = 2
ORDER BY valid_from;

Now the payoff: re-run finance's revenue-by-plan report after the change, joining each order to the plan that was current on the order date. Liam's old orders stay filed under Basic. The closed quarter keeps its answer.

UPDATE dim_customer_scd
SET valid_to = DATE '2026-06-30', is_current = FALSE
WHERE customer_id = 2 AND is_current;

INSERT INTO dim_customer_scd
SELECT (SELECT max(customer_key) + 1 FROM dim_customer_scd),
       customer_id, name, city, country,
       'Pro', DATE '2026-07-01', NULL, TRUE
FROM stg_customers
WHERE customer_id = 2;

SELECT d.plan,
       ROUND(SUM(f.line_revenue), 2) AS completed_revenue
FROM fct_order_line f
JOIN dim_customer c ON f.customer_key = c.customer_key
JOIN dim_customer_scd d
  ON c.customer_id = d.customer_id
 AND f.date_key BETWEEN d.valid_from
                    AND coalesce(d.valid_to, DATE '9999-12-31')
WHERE f.status = 'completed'
GROUP BY d.plan
ORDER BY d.plan;
Try the lie for contrast. In the box above, replace the BETWEEN join with JOIN dim_customer_scd d ON c.customer_id = d.customer_id AND d.is_current and re-run. Liam's revenue jumps to Pro - that is the Type 1 behavior you just engineered your way out of.
Demo 2 of 2

Automate it: MERGE, then your turn ★ 10 min · everyone builds

Real loads do not hand-write one UPDATE per customer. They receive today's snapshot of the source and reconcile it against the dimension: changed customers get closed out, brand-new customers get inserted - one MERGE decides which is which. A final INSERT then opens the new version rows for the changed ones.

LiveThe MERGE version of tonight's load6 min

Tonight's snapshot carries two stories: Liam (exists, plan changed) and Ivy Nolan (customer 16, never seen before). Watch the ON clause - it matches only current rows whose plan actually differs, so unchanged customers pass through untouched. Batch keys land at durable id + 100 so you can spot tonight's arrivals at a glance.

MERGE INTO dim_customer_scd t
USING (
  SELECT * FROM (VALUES
    (2,  'Liam Ford', 'Austin', 'USA',   'Pro'),
    (16, 'Ivy Nolan', 'Osaka',  'Japan', 'Basic')
  ) v(customer_id, name, city, country, plan)
) src
ON t.customer_id = src.customer_id
   AND t.is_current AND t.plan <> src.plan
WHEN MATCHED THEN
  UPDATE SET valid_to = DATE '2026-06-30', is_current = FALSE
WHEN NOT MATCHED THEN
  INSERT VALUES (src.customer_id + 100, src.customer_id, src.name,
                 src.city, src.country, src.plan,
                 DATE '2026-07-01', NULL, TRUE);

INSERT INTO dim_customer_scd
SELECT d.customer_key + 100, d.customer_id, d.name, d.city, d.country,
       'Pro', DATE '2026-07-01', NULL, TRUE
FROM dim_customer_scd d
WHERE d.customer_id = 2 AND d.valid_to = DATE '2026-06-30';

SELECT customer_id, plan, valid_from, valid_to, is_current
FROM dim_customer_scd
WHERE customer_id IN (2, 16)
ORDER BY customer_id, valid_from;

Read the result: Liam has two rows (Basic sealed, Pro open), Ivy has one fresh row. Three situations - changed, new, unchanged - one repeatable script. This exact reconcile shape returns as the loading workhorse in b6.

LiveYour turn: Zoe Tan moves Singapore to Tokyo4 min

Zoe Tan (customer 14) relocates on July 1. Write the three-move Type 2 change yourself before you peek - close out, insert with the new city and country, read the timeline. The box below holds a working answer.

UPDATE dim_customer_scd
SET valid_to = DATE '2026-06-30', is_current = FALSE
WHERE customer_id = 14 AND is_current;

INSERT INTO dim_customer_scd
SELECT (SELECT max(customer_key) + 1 FROM dim_customer_scd),
       customer_id, name, 'Tokyo', 'Japan',
       plan, DATE '2026-07-01', NULL, TRUE
FROM stg_customers
WHERE customer_id = 14;

SELECT customer_id, city, country, valid_from, valid_to, is_current
FROM dim_customer_scd
WHERE customer_id = 14
ORDER BY valid_from;
Self-studyPoint-in-time: what plan was Liam on, order by order?3 min

The BETWEEN join answers per-fact questions too. After the upgrade, ask the warehouse what plan Liam was on the day each of his orders happened - both answer Basic, because both predate July. Any order he places from July onward will answer Pro, with no query change.

UPDATE dim_customer_scd
SET valid_to = DATE '2026-06-30', is_current = FALSE
WHERE customer_id = 2 AND is_current;

INSERT INTO dim_customer_scd
SELECT (SELECT max(customer_key) + 1 FROM dim_customer_scd),
       customer_id, name, city, country,
       'Pro', DATE '2026-07-01', NULL, TRUE
FROM stg_customers
WHERE customer_id = 2;

SELECT f.order_id, f.date_key, d.plan AS plan_when_ordered
FROM fct_order_line f
JOIN dim_customer c ON f.customer_key = c.customer_key
JOIN dim_customer_scd d
  ON c.customer_id = d.customer_id
 AND f.date_key BETWEEN d.valid_from
                    AND coalesce(d.valid_to, DATE '9999-12-31')
WHERE c.customer_id = 2
ORDER BY f.date_key;
Homework

Try it yourself - this week ◐ 20-30 min total

Source material

Official sources covered

This track teaches the working core of the major data-warehousing curricula, run on a live engine instead of slides. Certificates, graded labs, and videos stay on the official platforms. This page covers:

365DS Intro to Data Warehousing - S7: slowly changing dimensionsParts 1-3 + both demos · all four types, Type 2 executed live
DeepLearning.AI Data Engineering C4 (Joe Reis) - modeling change over timePart 2 · the data-vault contrast to SCD lands in b9
IBM Data Warehouse Fundamentals (Coursera) - dimension maintenanceDemos 1-2 · IBM's populate-and-maintain labs continue in b6
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Marketing wants "revenue by the plan the customer was on at purchase time" to stay correct forever. Which SCD type does the customer plan column need?

Type 2 keeps every version as its own row, so old facts keep joining to the value that was true when they happened. Type 1 rewrites history; Type 0 would reject a real upgrade.

2 · Why does Type 2 require a surrogate key like customer_key on top of customer_id?

customer_id identifies the person and repeats across versions; customer_key identifies one version. Without it, "one row per version" has no primary key - and facts cannot pin themselves to a specific version.

3 · The point-in-time join matches a fact to the right dimension version with which predicate?

Each version owns a validity window; the fact's date falls into exactly one. The coalesce turns the open-ended current row's NULL into "end of time" so BETWEEN can evaluate it.

Builder session 5 cheat sheet · pin this

The overwrite lieUpdating a dimension in place silently re-files every historical fact under the new value. Closed quarters change their answers.
Type 0 / 10: freeze birth facts forever. 1: overwrite - honest only when the old value was never true (typo fixes).
Type 2New row per version + valid_from, valid_to, is_current. The workhorse: full history, point-in-time joins.
Type 3A previous-value column. One step of history only - the second change erases the first. Rare, for good reason.
Surrogate vs durable keycustomer_key names a version; customer_id names the person. Type 2 is impossible without both.
The three movesClose the old row (stamp valid_to, is_current FALSE), insert the new version (fresh key, open-ended), verify the timeline.
Current vs point-in-timeNow: WHERE is_current. Then: fact_date BETWEEN valid_from AND coalesce(valid_to, DATE '9999-12-31').
MERGE reconcileOne statement routes today's snapshot: changed rows close out, new customers insert, unchanged pass through. Star of b6.