Skip to main content

SCD2 Under the Clock: Apply the Change Batch

Level 7: Warehouses, Lakehouse & Dimensional Modelinghard30 minSCD Type 2change-batch applicationchange-detection guardsclose-and-insert scriptsidempotent scriptsas-of joinstimed rehearsal

A timed rehearsal of the interview's stock modeling scenario: apply an incoming multi-driver change batch to a Type 2 dimension with a script that is safe to run twice.

The review screen (you already learned this)

Level 4's "SCD Type 2" lesson and its loader capstone built the mechanism, and Level 5's "Point-in-Time (As-Of) Joins Against an SCD2 Dimension" graded the join predicate. Neither is retaught here. The compressed version:

  • A Type 2 dimension keeps history by adding a version row instead of overwriting. One driver, several rows, each with valid_from, valid_to, and is_current.
  • The surrogate driver_key identifies a version, not a driver. The natural driver_id identifies the driver.
  • Applying a change is two statements: close the open row by setting its valid_to to the effective date and is_current = 0, then insert the new open row starting at that same date.
  • The as-of join predicate is fact_date >= valid_from AND fact_date < valid_to. Half-open on purpose: the closed row's valid_to equals the new row's valid_from, so a fact landing exactly on the change date belongs to exactly one version.
Table
Driver 42's two version rows. The windows touch but never overlap, so every trip attributes to exactly one city.
driver_keydriver_idhome_cityvalid_fromvalid_tois_currenttrip landing in this window
142Detroit2025-09-012026-03-010T-2002 on 2026-02-20 ($31.00)
242Ann Arbor2026-03-019999-12-311T-2003 on 2026-04-05 ($18.75)
Driver 42's two version rows. The windows touch but never overlap, so every trip attributes to exactly one city.

What this lesson adds: the batch, under the clock

Interviewers do not hand you one change. They hand you a batch, and roughly 25 minutes, and they watch you narrate. The batch is where candidates who know the concept still lose the round, for three reasons:

  1. No change detection. The batch contains a row for a driver whose city did not actually change. Apply it blindly and you create a version row that says the driver moved from Detroit to Detroit. History is now a lie.
  2. Rows that should not be touched. Drivers with no row in the batch must come out byte-identical. A WHERE is_current = 1 with no batch predicate closes everybody.
  3. Not safe to run twice. Pipelines retry. If your script doubles the versions when it runs again, it is not a loader, it is a one-shot.

The shape that survives all three

-- 1. Close the open version, but ONLY where the incoming city actually differs.
UPDATE dim_driver_scd
SET valid_to = (SELECT u.effective_date FROM driver_updates u
                WHERE u.driver_id = dim_driver_scd.driver_id),
    is_current = 0
WHERE is_current = 1
  AND EXISTS (SELECT 1 FROM driver_updates u
              WHERE u.driver_id = dim_driver_scd.driver_id
                AND u.new_city <> dim_driver_scd.home_city);

-- 2. Open the new version, skipping any driver that already has it.
INSERT INTO dim_driver_scd (driver_id, driver_name, home_city, valid_from, valid_to, is_current)
SELECT u.driver_id, d.driver_name, u.new_city, u.effective_date, '9999-12-31', 1
FROM driver_updates u
JOIN dim_driver_scd d
  ON d.driver_id = u.driver_id AND d.valid_to = u.effective_date
WHERE NOT EXISTS (
  SELECT 1 FROM dim_driver_scd c
  WHERE c.driver_id = u.driver_id
    AND c.home_city = u.new_city
    AND c.valid_from = u.effective_date
);

The EXISTS ... AND u.new_city <> home_city in step 1 is the change-detection guard: a no-op row never closes anything. The NOT EXISTS in step 2 is the idempotency guard: on a second run the version already exists, so nothing is inserted. Step 2 joins on d.valid_to = u.effective_date, which is exactly the row step 1 just closed, so a driver whose row never closed never gets a new version either.

This shape assumes at most one change per driver in the batch. When a batch carries two changes for the same driver, that scalar subquery in step 1 has no defined answer, and the close date has to be the earliest effective date while the new versions chain between the rest. That is the practice exercise.

Common mistake: closing the old row by deleting it. That is a Type 1 overwrite wearing Type 2 columns. Every historical fact that joined to that version now joins to nothing, and last quarter's revenue by city silently changes. The old row stays forever; only its valid_to and is_current move.

Interview nuance: most candidates answer "which SCD type?" with the number and stop. The complete answer is the number plus the join predicate: "Type 2, so each trip joins on driver_id with completed_at >= valid_from AND completed_at < valid_to, which attributes revenue to the city the driver lived in that day." Say the predicate. It is the sentence that proves you have built one.

Forward pointer: where does the change batch come from on a real platform? A CDC stream off the source database's write-ahead log. Level 9 builds that producer; this lesson is its consumer.

On a real platform this differs. A warehouse writes both statements as a single MERGE INTO dim_driver_scd USING driver_updates ON ... WHEN MATCHED AND target.home_city <> source.new_city THEN UPDATE SET ... WHEN NOT MATCHED THEN INSERT ..., which is atomic and is what Snowflake, BigQuery, Databricks, and Redshift all expect to see. SQLite has no MERGE, so you write the same semantics as an explicit close-then-insert (or INSERT ... ON CONFLICT(key) DO UPDATE SET ... for a Type 1 upsert). The guards you are writing here are exactly the WHEN MATCHED AND ... conditions of the real statement.

Sample data for this example
CREATE TABLE dim_driver_scd (
  driver_key  INTEGER PRIMARY KEY,   -- surrogate key: one per VERSION row, not per driver
  driver_id   INTEGER,               -- the natural key from the source system
  driver_name TEXT,
  home_city   TEXT,
  valid_from  TEXT,
  valid_to    TEXT,                  -- '9999-12-31' on the open (current) version
  is_current  INTEGER
);
INSERT INTO dim_driver_scd (driver_key, driver_id, driver_name, home_city, valid_from, valid_to, is_current) VALUES
  (1, 42, 'Maya Okafor', 'Detroit',   '2025-09-01', '2026-03-01', 0),
  (2, 42, 'Maya Okafor', 'Ann Arbor', '2026-03-01', '9999-12-31', 1),
  (3, 51, 'Ivan Petrov', 'Ann Arbor', '2025-10-15', '9999-12-31', 1),
  (4, 63, 'Lena Diaz',   'Chicago',   '2025-11-02', '9999-12-31', 1),
  (5, 74, 'Sam Byrne',   'Detroit',   '2026-01-20', '9999-12-31', 1),
  (6, 85, 'Priya Raman', 'Ann Arbor', '2026-02-08', '9999-12-31', 1);
CREATE TABLE fact_trips (
  trip_id      TEXT,
  driver_id    INTEGER,
  completed_at TEXT,
  fare_usd     REAL
);
INSERT INTO fact_trips (trip_id, driver_id, completed_at, fare_usd) VALUES
  ('T-2001', 42, '2026-01-15', 24.50),
  ('T-2002', 42, '2026-02-20', 31.00),
  ('T-2003', 42, '2026-04-05', 18.75),
  ('T-2004', 42, '2026-05-11', 27.25),
  ('T-2005', 51, '2026-02-02', 15.60),
  ('T-2006', 63, '2026-03-18', 42.10),
  ('T-2007', 74, '2026-04-22', 33.40),
  ('T-2008', 85, '2026-05-30', 21.05),
  ('T-2009', 63, '2026-05-09', 29.90);
Worked example (SQL)
-- Review (Level 5 graded this): the as-of join splits driver 42's revenue across both cities.
SELECT d.home_city, d.valid_from, d.valid_to,
       COUNT(f.trip_id) AS trips,
       ROUND(SUM(f.fare_usd), 2) AS revenue
FROM dim_driver_scd d
LEFT JOIN fact_trips f
  ON f.driver_id = d.driver_id
 AND f.completed_at >= d.valid_from
 AND f.completed_at <  d.valid_to
WHERE d.driver_id = 42
GROUP BY d.home_city, d.valid_from, d.valid_to
ORDER BY d.valid_from;

Apply

Your turn

The task this lesson builds to.

Write a script that applies driver_updates to dim_driver_scd as Type 2 changes. Give yourself a 25-minute target and narrate as you go, the way you would in the room.

dim_driver_scd(driver_key, driver_id, driver_name, home_city, valid_from, valid_to, is_current) is seeded with six version rows (driver 42 already moved on 2026-03-01). driver_updates(driver_id, new_city, effective_date) holds the incoming batch.

For each driver whose city genuinely changes: close the current row by setting valid_to to the effective date and is_current = 0, then insert a new row starting at that date with valid_to = '9999-12-31' and is_current = 1. Leave unchanged drivers and no-op updates (same city) completely untouched, and make the script safe to run twice.

5 hints and 8 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, plus 2 bonus drills.

Write a script that applies a harder batch: one driver changes cities twice, with two different effective dates. Same 25-minute target, no starter scaffold this time.

dim_driver_scd holds one open version per driver. driver_updates(driver_id, new_city, effective_date) holds three rows, two of them for the same driver.

The finished dimension must chain that driver's versions in effective-date order with continuous windows (each version's valid_to equals the next version's valid_from), leave exactly one is_current = 1 row per driver, leave drivers absent from the batch untouched, and produce the same result when the script runs a second time.

3 hints and 8 automated checks are waiting in the workspace.