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.
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.
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.
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.
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.
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;
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.
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;
Try it yourself - this week ◐ 20-30 min total
- Run a second change for the same customer: after Liam's July upgrade, downgrade him back to Basic on September 1. His timeline should show three sealed-and-open windows in order.
- Pick three attributes from a dimension at work (or from Daybreak:
plan,city,signup_date) and assign each an SCD type using Part 2's checklist. Write one sentence of justification per attribute. - Break the timeline on purpose: give the new version
valid_from = DATE '2026-06-15'so windows overlap, then run the point-in-time report and watch Liam's June revenue double-count. Overlaps are the classic Type 2 bug. - Explain to a colleague why
customer_keyandcustomer_idmust be different columns in a Type 2 world. If they push back, Part 2's card has your script. - Bring one "who changed this dimension" mystery from work to b6 - loading patterns are where changes get caught systematically.
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:
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.