Skip to content
Good engineers know 6 min read · All formats 1 practice problem ↓

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.

Read first: MERGE INTO and upserts · eqNullSafe (<=>) (skip if you know them)

Types 1, 2 and 3

TypeOn changeHistoryTypical use
Type 1Overwrite the attributeNoneCorrections, attributes nobody reports on historically
Type 2Close the current row, insert a new versionFullCustomer segment, address, price: anything facts must be joined to as it was
Type 3Keep the previous value in an extra columnOne step backRare: "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

ColumnPurpose
customer_idThe 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_toWhen this version was true. The current row has valid_to null or a far-future date such as 9999-12-31.
is_currentA 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:

  1. 1
    Copy 1 keeps the real key as merge_key. It matches the current target row, and the MATCHED clause closes it.
  2. 2
    Copy 2 has merge_key set to null, so it can never match. The NOT MATCHED clause inserts it as the new current version.
  3. 3
    Brand-new customers only need copy 1: they do not match anything and are inserted.
PySparkSpark SQL · SCD Type 2 with a staged union
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

PySparkSpark SQL · Join facts to the version that was valid at the time
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.city is 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.
Note: managed pipelines can do this for you: Databricks declarative pipelines (formerly Delta Live Tables) have 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."

Columns: business key, tracked attributes, valid_from, valid_to (null for current), is_current, optionally a surrogate key and a row hash. Each run: find rows that are new or differ from the current version (null-safe or by hash), stage changed rows twice (once with the real key to close the current row, once with a null merge key to insert the new version), and MERGE: matched closes, not matched inserts. Mention deduplicating or sequencing multiple changes per key, late changes, null-safe comparison, idempotent reruns, and the half-open interval join for facts.

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?"
The business key is no longer unique, since there is one row per version. A surrogate key identifies one version, so facts can store it at load time and join with a simple equality instead of a date-range join, which is faster and unambiguous.
Scenario"A batch contains three changes for the same customer on the same day. What do you do?"
Decide whether intermediate versions matter. If not, keep the latest per key before merging. If they do, build all versions first: order changes by sequence, set each valid_to with 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."
A change from null to a value, or back, gives null, not true, so it is missed. Use null-safe comparison (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 questions

Why 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.

Solve: Build an SCD Type 2 History →

Keep going

Up next · lesson 11 of 30 · 4 min read
Schema enforcement and evolution
What a table rejects on write, how mergeSchema adds columns, and which changes are safe.

Related lessons

Previous: MERGE INTO and upserts

Primary sources: Delta Lake: SCD Type 2 with merge · MERGE INTO (Delta)