Skip to content
Reliable Data Engineering
Lesson
Open in the interactive app

Slowly Changing Dimensions: Types 0 to 7

“Customer moved from Berlin to Munich. What happens to last year’s sales by city?” That one question is what SCD types answer.


1. All types at a glance

Scenario: customer C-42 moves from Berlin to Munich on 2026-06-01.

TypeNameWhat happensLast year’s sales by cityUse when
0Retain originalNever change (e.g. original_signup_city)BerlinAttribute is fixed by definition
1Overwritecity = MunichMunich (history rewritten)Corrections, attributes where history doesn’t matter
2Add new rowClose Berlin row, insert Munich row with new surrogate keyBerlin (facts point to the old version)Need history “as it was” (most common)
3Add columncurrent_city = Munich, previous_city = BerlinChoice of eitherLimited history (one prior value), e.g. sales territory realignment
4History tableCurrent table (Munich) + separate history tableVia history tableRapidly changing attributes; keep main dim small
5Mini-dimension + type 1 outriggerMini-dim for volatile attributes, current key copied to base dimFlexibleLarge dims with volatile banded attributes
6Hybrid 1+2+3SCD2 rows plus a current_city column overwritten on all rowsBoth “as was” and “as is” easilyAnalysts need both perspectives often
7Dual keysFact stores both the surrogate key (as was) and the durable natural key (join to current view)BothSame as 6, with cleaner implementation

2. SCD Type 2 in detail

customer_key | customer_id | city    | tier   | valid_from | valid_to   | is_current
-------------|-------------|---------|--------|------------|------------|-----------
101          | C-42        | Berlin  | silver | 2024-03-10 | 2026-06-01 | false
257          | C-42        | Munich  | silver | 2026-06-01 | 9999-12-31 | true

How facts use it

At load time (preferred): look up the version valid at the event time and store its surrogate key in the fact:

SELECT o.order_id, d.customer_key, o.amount
FROM staging_orders o
LEFT JOIN dim_customer d
  ON d.customer_id = o.customer_id
 AND o.order_ts >= d.valid_from AND o.order_ts < d.valid_to;

Then “sales by city as it was” is a simple equi-join on customer_key. “Sales by current city” joins through customer_id to is_current = true (that’s type 7).

Implementing SCD2 with MERGE (Delta / Snowflake / BigQuery)

The trick: stage each changed key twice: one row to close the old version (matched by key) and one row to insert the new version (with a NULL merge key so it never matches).

MERGE INTO dim_customer t
USING (
  -- rows that update/close existing current versions
  SELECT s.customer_id AS merge_key, s.* FROM staged s
  UNION ALL
  -- rows that insert new versions for changed customers (merge_key NULL never matches)
  SELECT NULL AS merge_key, s.*
  FROM staged s JOIN dim_customer t
    ON s.customer_id = t.customer_id AND t.is_current AND s.row_hash <> t.row_hash
) s
ON t.customer_id = s.merge_key AND t.is_current
WHEN MATCHED AND t.row_hash <> s.row_hash THEN
  UPDATE SET t.is_current = false, t.valid_to = s.effective_ts
WHEN NOT MATCHED THEN
  INSERT (customer_key, customer_id, city, tier, row_hash, valid_from, valid_to, is_current)
  VALUES (xxhash64(s.customer_id, s.effective_ts), s.customer_id, s.city, s.tier, s.row_hash,
          s.effective_ts, TIMESTAMP '9999-12-31', true);

Brand-new customers fall into NOT MATCHED via the first branch (no current row to match). Unchanged customers match with equal hashes and do nothing.

dbt snapshots

{% snapshot customers_snapshot %}
{{ config(target_schema='snapshots', unique_key='customer_id',
          strategy='check', check_cols=['city', 'tier'], invalidate_hard_deletes=True) }}
SELECT * FROM {{ source('crm', 'customers') }}
{% endsnapshot %}

strategy='timestamp' (uses updated_at) is cheaper and more reliable when the source maintains it; check compares columns.

Databricks DLT / Lakeflow

CREATE FLOW customers_scd2 AS AUTO CDC INTO silver.dim_customer
FROM STREAM(bronze.customers_cdc)
KEYS (customer_id) SEQUENCE BY lsn
APPLY AS DELETE WHEN op = 'd'
STORED AS SCD TYPE 2
TRACK HISTORY ON city, tier;

3. Edge cases interviewers love

CaseHandling
Multiple changes for the same key in one batchProcess in order (window by key ordered by effective time; LEAD for valid_to) or you’ll lose intermediate versions
Late-arriving change (effective date in the past)Must split an existing version: insert the new version and adjust valid_to of the prior one, and possibly re-key affected facts. Painful, so mention it and the cost
Change in an untracked columnSCD1 update in place on all versions, or ignore
NULL handling in change detectionNULL <> 'x' is UNKNOWN → use null-safe comparison (IS DISTINCT FROM, <=>) or hash of coalesced values
Hard deletes in sourceClose the current row (valid_to = delete time, is_deleted = true)
Re-running the same loadMust be idempotent: unchanged hashes → no new versions
Dimension explodes (attribute changes daily)Move volatile attributes to a mini-dimension (type 4/5) or a periodic snapshot fact

4. SCD2 vs snapshots vs time travel

ApproachProsCons
SCD2 dimensionCompact, explicit history, point-in-time joinsLogic complexity, late changes are painful
Daily full snapshots (dim_customer_daily)Trivial logic, easy point-in-timeStorage grows linearly (fine with columnar compression for small dims), only daily granularity
Table time travel (Delta/Iceberg)FreeRetention-limited (VACUUM), not a modelling tool. Use for recovery/audit, not business history

5. Interview questions

Why do SCD2 dimensions need surrogate keys?

The natural key repeats across versions, so it can’t be a primary key. Facts must reference a specific version, which only a surrogate (version-level) key can do. Surrogates also insulate the warehouse from source key changes and collisions across sources.

An analyst asks for "revenue by customer's current segment" and "revenue by segment at time of purchase". How does your model support both?

Facts store the version surrogate key (as-was via direct join). For as-is, either a type 6 current_segment column on every version, or type 7: join through the durable natural key to the current row (is_current = true), often exposed as a dim_customer_current view.

How do you make SCD2 loads idempotent?

Compare a hash of tracked attributes with the current version; insert a new version only when it differs; derive valid_from from source effective time (not load time) so re-runs produce the same rows; deterministic surrogate keys (hash of natural key + valid_from).