Slowly changing dimensions (SCD Type 2)
A customer moves city. Do you overwrite the old address, or keep both and record when each was true? Slowly changing dimensions are the standard answers, and Type 2, keeping history, is the one interviews ask you to build.
On this page
Show code in
Every code block on the page follows this.
Read first: MERGE INTO and upserts · eqNullSafe (<=>) (skip if you know them)
Types 1, 2 and 3
| Type | On change | History | Typical use |
|---|---|---|---|
| Type 1 | Overwrite the attribute | None | Corrections, attributes nobody reports on historically |
| Type 2 | Close the current row, insert a new version | Full | Customer segment, address, price: anything facts must be joined to as it was |
| Type 3 | Keep the previous value in an extra column | One step back | Rare: "previous region" after a reorganisation |
Type 2 matters because facts must join to the dimension as it was when the fact happened. A March order from a customer who moved in May belongs to the old city in a March report.
The Type 2 columns
| Column | Purpose |
|---|---|
customer_id | The business key. Not unique any more: one row per version. |
customer_sk (optional) | A surrogate key unique per version, which facts can store directly. |
valid_from, valid_to | When this version was true. The current row has valid_to null or a far-future date such as 9999-12-31. |
is_current | A convenience flag so "current state" queries need no date logic. |
row_hash (optional) | A hash of the tracked attributes, to detect changes with one comparison. |
One MERGE, two actions
For a changed customer, the batch must do two things to the target: update the current row (close it) and insert the new version. One MERGE clause can only do one of those per source row. The standard trick stagesstage: A group of steps Spark can run without moving data between machines. A new stage starts at every shuffle. Learn more → every changed row twice:
- 1Copy 1 keeps the real key as
merge_key. It matches the current target row, and the MATCHED clause closes it. - 2Copy 2 has
merge_keyset to null, so it can never match. The NOT MATCHED clause inserts it as the new current version. - 3Brand-new customers only need copy 1: they do not match anything and are inserted.
spark.sql(""" WITH changes AS ( -- rows that are new or differ from the current version SELECT s.* FROM updates s LEFT JOIN customers t ON t.customer_id = s.customer_id AND t.is_current WHERE t.customer_id IS NULL -- new customer OR NOT (t.city <=> s.city) -- changed attribute (null-safe) ), staged AS ( SELECT customer_id AS merge_key, * FROM changes -- copy 1: closes the old row UNION ALL SELECT NULL AS merge_key, * FROM changes c -- copy 2: inserts the new row WHERE EXISTS (SELECT 1 FROM customers t WHERE t.customer_id = c.customer_id AND t.is_current) ) MERGE INTO customers t USING staged s ON t.customer_id = s.merge_key AND t.is_current WHEN MATCHED THEN UPDATE SET is_current = false, valid_to = s.changed_at WHEN NOT MATCHED THEN INSERT (customer_id, city, valid_from, valid_to, is_current) VALUES (s.customer_id, s.city, s.changed_at, NULL, true) """)
WITH changes AS ( -- rows that are new or differ from the current version SELECT s.* FROM updates s LEFT JOIN customers t ON t.customer_id = s.customer_id AND t.is_current WHERE t.customer_id IS NULL -- new customer OR NOT (t.city <=> s.city) -- changed attribute (null-safe) ), staged AS ( SELECT customer_id AS merge_key, * FROM changes -- copy 1: closes the old row UNION ALL SELECT NULL AS merge_key, * FROM changes c -- copy 2: inserts the new row WHERE EXISTS (SELECT 1 FROM customers t WHERE t.customer_id = c.customer_id AND t.is_current) ) MERGE INTO customers t USING staged s ON t.customer_id = s.merge_key AND t.is_current WHEN MATCHED THEN UPDATE SET is_current = false, valid_to = s.changed_at WHEN NOT MATCHED THEN INSERT (customer_id, city, valid_from, valid_to, is_current) VALUES (s.customer_id, s.city, s.changed_at, NULL, true)
Only changed rows are staged, so unchanged customers are not rewritten. With many tracked columns, compare a hash of them instead: t.row_hash <> s.row_hash, computed with null-safe concatenation (for example sha2(concat_ws('|', coalesce(city, '∅'), ...), 256)).
Querying history
spark.sql(""" SELECT o.order_id, o.order_ts, c.city FROM orders o JOIN customers c ON c.customer_id = o.customer_id AND o.order_ts >= c.valid_from AND (c.valid_to IS NULL OR o.order_ts < c.valid_to) """)
SELECT o.order_id, o.order_ts, c.city FROM orders o JOIN customers c ON c.customer_id = o.customer_id AND o.order_ts >= c.valid_from AND (c.valid_to IS NULL OR o.order_ts < c.valid_to)
Use a half-open interval (>= start, < end) so a fact exactly at a change time matches exactly one version. This is a range join: if it is slow, storing the surrogate key on the fact at load time avoids it.
Edge cases
- Several changes for one key in one batch — The MERGE fails on duplicate matches, or keeps only one change. Either keep the latest per key (losing intermediate history), or apply changes in sequence order, one version per change, by building the versions with
lead(changed_at)before merging. - Late, out-of-order changes — A change dated before the current version's start must be inserted in the middle of history, splitting a version. The simple MERGE assumes changes arrive in order; handle late ones separately or rebuild the key's history.
- Re-running a batch — Without the change check, a rerun closes and reinserts every row again. The "differs from current" filter makes reruns no-ops.
- Null attributes —
t.city != s.cityis null when either side is null, so a change to or from null is missed. Use<=>or compare hashes. - Deletes — Decide what a source delete means: close the current row with no new version, or insert a version flagged as deleted.
APPLY CHANGES (now AUTO CDC) with STORED AS SCD TYPE 2, which also handles out-of-order changes by a sequence column. Knowing the manual MERGE is still what interviews test.Common mistakes
- Updating in place and calling it Type 2 — Without closing and inserting, history is lost.
- Comparing attributes with != — Changes to or from null are missed.
- Closed intervals for valid_from / valid_to — A fact at the boundary matches two versions.
Interview prep
THE QUESTION
"Implement an SCD Type 2 customer dimension on Delta, loaded from a daily change batch."
Avoid saying: "add an updated_at column and overwrite the row". That is Type 1 with a timestamp; the old version is gone.
What the interviewer asks next. Answer out loud first, then open the strong answer.
Follow-up"Why use a surrogate key in a Type 2 dimension?"
Scenario"A batch contains three changes for the same customer on the same day. What do you do?"
lead(changed_at), then close the current target row once and insert all new versions, with only the last one current.Trap"Detect changes with t.address != s.address."
NOT (t.address <=> s.address)) or compare hashes built with explicit null markers.What you learned
- The difference between SCD Types 1, 2 and 3
- The columns a Type 2 table needs, and why
- How to apply a batch of changes with one MERGE, using the staged-union trick
- How to query history, and the edge cases that break naive pipelines
Key takeaways
- Type 1 overwrites, Type 2 keeps every version, Type 3 keeps one previous value.
- Type 2 needs valid_from, valid_to and usually is_current per version.
- Stage changed rows twice so one MERGE can close the old version and insert the new one.
- Compare null-safely, handle multiple and late changes, and join facts with a half-open interval.
Check yourself
3 questionsWhy is each changed row staged twice?
Show the answer
One copy closes the current version (matched), the other inserts the new one (not matched). A single source row can only trigger one MERGE action.
A fact at exactly the change time must match one version. Which interval do you use?
Show the answer
valid_from <= ts < valid_to. A half-open interval assigns boundary instants to exactly one version.
The source city changes from null to "Pune", and the change is not picked up. Why?
Show the answer
null != "Pune" is null, not true. Use <=> or compare hashes.
Practice it
Interview problems that use this: write the PySpark, run it, and get graded on hidden tests.
Keep going
Up next · lesson 11 of 30 · 4 min readSchema enforcement and evolution
What a table rejects on write, how mergeSchema adds columns, and which changes are safe.
Related lessons
MERGE INTO and upsertsData lake & lakehouse · 3 min read
Change Data FeedPySpark functions · 3 min read
eqNullSafe (<=>)
Previous: MERGE INTO and upserts
Primary sources: Delta Lake: SCD Type 2 with merge · MERGE INTO (Delta)