Course 4 · The Craft

Slowly-Changing Dimensions & History

A dimension's attributes don't stay still — a GPU's hourly price changes, a customer moves up a tier. If you overwrite the old value, you quietly destroy the answer to a vital question: what was true at the time the event happened? This chapter teaches the patterns that keep history intact, with Type 2 — the one you'll reach for again and again — at the centre.

Setup — do this first

You only need DuckDB for this chapter — every query below runs in the DuckDB shell with no external data. Open it from your mini-griddp repo with duckdb (or python -c "import duckdb; duckdb.sql('...')"). Type each statement as you read; the muscle memory of building an SCD2 table by hand is the whole point.

The problem: dimension attributes change over time

In the last chapter you built a star schema: a fact_rentals table surrounded by dimensions like dim_gpu and dim_customer. Dimensions described who and what. The unspoken assumption was that a dimension row is a stable fact about the world. It isn't.

Consider one column: the hourly price of an A100 GPU. In January it rents for $2.00/hr. In March you raise it to $2.50. In May a price war drops it to $1.80. The GPU is the same physical card — same gpu_id — but one of its attributes has a history.

Now suppose your dim_gpu table holds a single price_per_hour column and you simply overwrite it each time the price changes. Today it says $1.80. Someone asks an entirely reasonable question:

"How much revenue did we earn from A100 rentals in February?"

If your report multiplies February's rental hours by the current price, you get a number computed at $1.80 — but in February the price was $2.00. The report is wrong, and worse, it's silently wrong: nothing errors, the query runs, a confident-looking number appears. Last month the same query returned a different answer. Trust evaporates.

⚠ The silent-corruption trap

Overwriting a dimension value doesn't just lose history — it rewrites the past. Every historical report that joins to that dimension now uses today's value as if it had always been true. Numbers that were correct last week change with no code change and no warning. This is one of the most common ways a data team loses the business's confidence.

The fix is to stop treating a dimension as "the current state" and start treating it as "the state over time." How much machinery you add to do that is exactly what the SCD types describe.

Type 1: overwrite the value

Type 1 is the no-history approach: when an attribute changes, you UPDATE the row in place. The old value is gone forever.

duckdb · SCD Type 1
-- A Type 1 update: the old price simply disappears
UPDATE dim_gpu
SET    price_per_hour = 2.50
WHERE  gpu_id = 'A100';

This is simple, cheap, and sometimes exactly right. Type 1 is acceptable when the attribute is a correction rather than a real change, or when nobody will ever need the prior value:

  • Fixing a typo in a customer's name ("Jhon" → "John") — the old spelling was never meaningfully true.
  • A field nobody reports on historically (an internal note, a display colour).
  • Late-binding reference data where only "now" matters.

The test is a single question: "Would anyone ever ask what this value was at some past moment?" If yes, Type 1 is the wrong choice — and for a price, the answer is emphatically yes.

Type 2: add a new row for each version

Type 2 is the workhorse, and the one worth truly understanding. Instead of overwriting, you add a new row every time an attribute changes, and you stamp each row with the window of time during which it was the truth. The business key (gpu_id) now appears on several rows — one per version — so you introduce a surrogate key that is unique per version.

The standard columns are:

ColumnPurpose
gpu_sk (surrogate key)Unique per version. The fact table joins to this.
gpu_id (business key)The real-world identity. Repeats across versions.
price_per_hourThe attribute value for this version.
valid_fromTimestamp this version became active.
valid_toTimestamp it stopped being active (open-ended for the live one).
is_currentBoolean flag — true for the one live version.

Here is the A100's price history as a Type 2 dimension. The same gpu_id spans three rows; each owns a slice of time:

A100 price over time (SCD Type 2) sk gpu_id price valid_from valid_to is_current ─── ────── ───── ────────── ────────── ────────── 101 A100 $2.00 2026-01-01 2026-03-01 false 102 A100 $2.50 2026-03-01 2026-05-01 false 103 A100 $1.80 2026-05-01 9999-12-31 true ◄─ live Jan ──────────── Mar ──────────── May ──────────────────▶ │ $2.00 (sk 101) │ $2.50 (sk 102) │ $1.80 (sk 103) └─────────────────┴─────────────────┴────────────────── a Feb rental falls here → joins to sk 101 → priced at $2.00

Notice how the windows abut: each valid_to equals the next row's valid_from. The live row's valid_to is a sentinel far in the future (9999-12-31) so range checks work without special-casing NULL. The is_current flag is redundant with that sentinel, but it makes "give me today's price" a trivial filter.

Now the crucial part: a fact joins to the version that was current at the event's timestamp, not to the latest version. A rental that started on 2026-02-14 falls inside row 101's window, so it joins to surrogate key 101 and is priced at $2.00 — correct, forever, no matter how many future price changes happen.

Why a surrogate key, really

The fact stores the surrogate key (gpu_sk = 101), not the business key. That single integer pins the rental to one exact version of the GPU's attributes. The fact becomes immutable: it captured the truth at load time and never needs revisiting when prices change later. This is the heart of why Type 2 dimensions and surrogate keys travel together.

Type 3: keep a "previous value" column

Type 3 is a middle ground you'll rarely use. Instead of new rows, you add a second column to hold the prior value:

duckdb · SCD Type 3
-- One extra column remembers exactly one previous value
UPDATE dim_gpu
SET    previous_price = price_per_hour,   -- shuffle current → previous
       price_per_hour = 2.50              -- set the new current
WHERE  gpu_id = 'A100';

This keeps history exactly one step deep. After a second change, the original $2.00 is lost — previous_price only ever holds the most recent former value. It's occasionally handy for "show this quarter vs. last quarter's territory assignment" where only the immediately prior value matters and you want it on the same row. For anything needing full history — which is almost everything in analytics — Type 2 is the answer. Here is the comparison at a glance:

TypeMechanismHistory keptUse when
Type 1Overwrite in placeNoneCorrections; no one queries the past value
Type 2New row per version + valid rangeFullAnything where "what was true then" matters (prices, tiers, status)
Type 3Extra "previous" columnOne stepRare; only the single prior value matters, on the same row

Point-in-time correctness: the "as-of" join

Everything above converges on one technique. To price a fact correctly you join it to the dimension version that was valid as of the fact's own timestamp. That's the as-of join, and it's the backbone of trustworthy history:

the shape of an as-of join
SELECT r.rental_id, r.hours, g.price_per_hour
FROM   fact_rentals r
JOIN   dim_gpu g
  ON   g.gpu_id = r.gpu_id
 AND   r.started_at >= g.valid_from     -- the event happened after this
 AND   r.started_at <  g.valid_to;      -- version began, and before it ended

The two range conditions are the magic. For any given started_at, exactly one version satisfies both — because the validity windows partition the timeline with no gaps or overlaps. Join on the business key for identity, then on the time range for the right version. (The >=/< asymmetry matters: it makes the boundary timestamp belong to exactly one window, never both.)

This idea outlives this chapter

"Use the value that was valid at event time, never the latest value" is point-in-time correctness, and it reappears far beyond dimensions. When you build features for a machine-learning model later in the curriculum, the single deadliest bug is leakage — letting a feature peek at information from after the prediction moment. The cure is the very same as-of discipline you're learning now. Master it on GPU prices and you've learned a pattern you'll use for the rest of your career.

Hands-on: an SCD2 dimension for GPU prices

Let's build it for real in DuckDB. We'll create a Type 2 price dimension, apply a price change with proper merge logic, record some rentals, and then compute revenue using the price that was active at each rental's moment.

Step 1 — create the dimension and seed two versions. The A100 starts at $2.00, then rises to $2.50 on March 1st.

duckdb
CREATE TABLE dim_gpu (
  gpu_sk         INTEGER,
  gpu_id         VARCHAR,
  price_per_hour DECIMAL(6,2),
  valid_from     DATE,
  valid_to       DATE,
  is_current     BOOLEAN
);

INSERT INTO dim_gpu VALUES
  (101, 'A100', 2.00, DATE '2026-01-01', DATE '2026-03-01', false),
  (102, 'A100', 2.50, DATE '2026-03-01', DATE '9999-12-31', true );

Step 2 — a new price arrives. On May 1st the price drops to $1.80. An SCD2 update is two moves: close the currently-live row, then insert the new live row. Never overwrite.

duckdb · the SCD2 merge
-- (a) close the old current version: set its valid_to and unflag it
UPDATE dim_gpu
SET    valid_to   = DATE '2026-05-01',
       is_current = false
WHERE  gpu_id = 'A100'
  AND  is_current = true;

-- (b) insert the new current version, open-ended into the future
INSERT INTO dim_gpu VALUES
  (103, 'A100', 1.80, DATE '2026-05-01', DATE '9999-12-31', true);

-- inspect the full history
SELECT * FROM dim_gpu ORDER BY valid_from;
✓ You should see

Three rows for A100: sk 101 ($2.00, Jan→Mar, false), sk 102 ($2.50, Mar→May, false), and sk 103 ($1.80, May→9999-12-31, true). Exactly one row has is_current = true, and each valid_to meets the next valid_from — no gaps, no overlaps. That contiguity is the invariant that makes as-of joins return exactly one match.

Step 3 — record some rentals at different times, then price them with an as-of join. Two rentals land in February (price $2.00), one in April ($2.50), one in June ($1.80).

duckdb
CREATE TABLE fact_rentals (
  rental_id  INTEGER,
  gpu_id     VARCHAR,
  started_at DATE,
  hours      INTEGER
);

INSERT INTO fact_rentals VALUES
  (1, 'A100', DATE '2026-02-10', 5),   -- price era: $2.00
  (2, 'A100', DATE '2026-02-20', 3),   -- price era: $2.00
  (3, 'A100', DATE '2026-04-15', 4),   -- price era: $2.50
  (4, 'A100', DATE '2026-06-05', 2);   -- price era: $1.80

-- revenue using the price that was ACTIVE at each rental's time
SELECT  r.rental_id,
        r.started_at,
        r.hours,
        g.price_per_hour                 AS price_at_rental_time,
        r.hours * g.price_per_hour       AS revenue
FROM    fact_rentals r
JOIN    dim_gpu g
  ON    g.gpu_id = r.gpu_id
 AND    r.started_at >= g.valid_from
 AND    r.started_at <  g.valid_to
ORDER BY r.started_at;
✓ You should see

Each rental priced by its own era: rental 1 → 5 × $2.00 = $10.00, rental 2 → 3 × $2.00 = $6.00, rental 3 → 4 × $2.50 = $10.00, rental 4 → 2 × $1.80 = $3.60. Total revenue $29.60. Compare that to the naive answer — multiplying every rental by the current $1.80 — which would total $25.20 and quietly misstate every pre-May rental. The as-of join is the difference between right and confidently-wrong.

In the real world you don't hand-write surrogate keys

We assigned 101, 102, 103 by hand to keep the mechanics visible. In production a sequence or hash generates them, and a tool — dbt snapshots (Chapter 07) or your warehouse's MERGE — performs the close-old-row / insert-new-row dance for you, every run, idempotently. The pattern is identical; you're just learning what the tool does under the hood so you can trust and debug it.

✓ Check yourself

  • Why does overwriting a dimension value make past reports wrong, not just lose history?
  • In an SCD2 table, what does the fact table store — the business key or the surrogate key — and why does that make the fact immutable?
  • Write the two range conditions of an as-of join from memory. Why is one >= and the other <?
  • How does point-in-time correctness here connect to feature leakage in machine learning?
Exercise — A customer upgrades tiers mid-month: SCD1 vs SCD2

Customer C7 is on the silver tier. On 2026-06-15 they upgrade to gold. They placed rentals on June 3rd (still silver) and June 20th (now gold). Show what dim_customer looks like under SCD1 versus SCD2, and explain which one lets you correctly report "how many rentals happened while the customer was silver?"

solution
-- ── SCD1: overwrite. One row, history destroyed. ──
-- dim_customer after the upgrade:
--   customer_id | tier
--   C7          | gold     ◄─ the 'silver' period is GONE
--
-- Asking "rentals while silver?" is now impossible — every C7
-- rental, including the June 3rd one, looks like it was 'gold'.

-- ── SCD2: a new row per version. Full history. ──
CREATE TABLE dim_customer (
  cust_sk INTEGER, customer_id VARCHAR, tier VARCHAR,
  valid_from DATE, valid_to DATE, is_current BOOLEAN
);
INSERT INTO dim_customer VALUES
  (1, 'C7', 'silver', DATE '2026-01-01', DATE '2026-06-15', false),
  (2, 'C7', 'gold',   DATE '2026-06-15', DATE '9999-12-31', true );

CREATE TABLE rentals (rental_id INT, customer_id VARCHAR, started_at DATE);
INSERT INTO rentals VALUES
  (10, 'C7', DATE '2026-06-03'),   -- silver era
  (11, 'C7', DATE '2026-06-20');   -- gold era

-- as-of join attaches the tier that was true AT rental time
SELECT  d.tier, count(*) AS rentals
FROM    rentals r
JOIN    dim_customer d
  ON    d.customer_id = r.customer_id
 AND    r.started_at >= d.valid_from
 AND    r.started_at <  d.valid_to
GROUP BY d.tier;
-- → silver | 1   (the June 3rd rental)
--   gold   | 1   (the June 20th rental)

Under SCD1 both rentals would be attributed to gold and the question "how many while silver?" can't be answered at all — the silver period no longer exists in the table. Under SCD2 each rental joins to the tier that was valid at its own timestamp, so the split is exactly right: one silver, one gold. That's the whole reason Type 2 exists.

Next

You can now keep history honest in a single dimension. Next we organise all your tables — raw, cleaned, and business-ready — into disciplined layers that make a whole platform legible. → The Medallion Architecture