Skip to content
Reliable Data Engineering
Practice problem hard scd2gaps-and-islandsdata-modeling
Solve it in the browser (SQL editor)

Build an SCD Type 2 Dimension from Daily Snapshots

Difficulty: Hard · Topics: scd2, gaps-and-islands, data-modeling · Asked at: Databricks, Snowflake, Airbnb, any data warehouse team

Problem

A source system only provides full daily snapshots of customers. Build SCD2 history for the tracked attributes (tier, city): one row per continuous version with valid_from (first snapshot date of the version), valid_to (the first date of the next version, or 9999-12-31 if current) and is_current (1/0). If a customer disappears from the latest snapshot, their last version is closed at the first snapshot date where they’re missing. Order by customer_id, valid_from.

Schema and sample data

CREATE TABLE customer_snapshots (snapshot_date TEXT, customer_id INTEGER, tier TEXT, city TEXT);
INSERT INTO customer_snapshots VALUES
('2026-01-01',1,'bronze','Berlin'),('2026-01-01',2,'gold','Paris'),
('2026-01-02',1,'bronze','Berlin'),('2026-01-02',2,'gold','Paris'),
('2026-01-03',1,'silver','Berlin'),('2026-01-03',2,'gold','Lyon'),('2026-01-03',3,'bronze','Rome'),
('2026-01-04',1,'silver','Berlin'),('2026-01-04',2,'gold','Lyon'),('2026-01-04',3,'bronze','Rome'),
('2026-01-05',1,'bronze','Berlin'),('2026-01-05',3,'bronze','Rome');

Expected output

customer_idtiercityvalid_fromvalid_tois_current
1bronzeBerlin2026-01-012026-01-030
1silverBerlin2026-01-032026-01-050
1bronzeBerlin2026-01-059999-12-311
2goldParis2026-01-012026-01-030
2goldLyon2026-01-032026-01-050
3bronzeRome2026-01-039999-12-311

Hints

Hint 1

Detect changes with LAG over the tracked columns; a change (or first appearance) starts a new version: flag + running sum.

Hint 2

valid_to of a version = valid_from of the next version; for the last version, check whether the customer is in the latest snapshot.

Solution

WITH s AS (
  SELECT *,
         CASE WHEN LAG(tier) OVER w IS NULL
                OR LAG(tier) OVER w <> tier
                OR LAG(city) OVER w <> city THEN 1 ELSE 0 END AS changed
  FROM customer_snapshots
  WINDOW w AS (PARTITION BY customer_id ORDER BY snapshot_date)
), v AS (
  SELECT *, SUM(changed) OVER (PARTITION BY customer_id ORDER BY snapshot_date ROWS UNBOUNDED PRECEDING) AS version
  FROM s
), versions AS (
  SELECT customer_id, version, tier, city,
         MIN(snapshot_date) AS valid_from, MAX(snapshot_date) AS last_seen
  FROM v GROUP BY customer_id, version, tier, city
), dates AS (
  SELECT DISTINCT snapshot_date FROM customer_snapshots
)
SELECT customer_id, tier, city, valid_from,
       COALESCE(
         LEAD(valid_from) OVER (PARTITION BY customer_id ORDER BY valid_from),
         (SELECT MIN(snapshot_date) FROM dates WHERE snapshot_date > versions.last_seen),
         '9999-12-31') AS valid_to,
       CASE WHEN LEAD(valid_from) OVER (PARTITION BY customer_id ORDER BY valid_from) IS NULL
             AND last_seen = (SELECT MAX(snapshot_date) FROM dates) THEN 1 ELSE 0 END AS is_current
FROM versions
ORDER BY customer_id, valid_from;

Explanation

In production you’d do this incrementally: compare today’s snapshot to the current rows (hash of tracked columns), close changed rows, insert new versions, via MERGE (Delta/Snowflake) or dbt snapshot with strategy: check.

Follow-up questions

Why compare a hash instead of each column?

sha2(concat_ws('||', tier, city)) makes the change test one comparison regardless of column count and handles NULLs if you coalesce first (NULL <> 'x' is UNKNOWN, so naive comparisons miss NULL→value changes).

How do fact tables join to this dimension?

On the business key and fact.event_date >= valid_from AND fact.event_date < valid_to, or better, resolve the surrogate key at fact load time and store customer_sk in the fact.