SCD Type 2 in SQL
Implementation and Point-in-Time Joins
SCD Type 2 keeps history by adding a row for every change to a tracked dimension attribute: the old row is closed at the change, and a new row opens with a new surrogate key and its own validity interval. Facts store the surrogate key of the version in effect when they happened, so past reports never change.
What is SCD Type 2?
SCD Type 2 is the slowly changing dimension technique that records a change by adding a row instead of overwriting one. When a tracked attribute of a customer or an account changes, the current row is closed at the moment of the change and a new row opens with the new value. That new row gets its own surrogate key and validity interval. Every past version stays in the table.
The point is correct history in facts. An order placed while a customer was on the basic plan should count toward basic revenue forever, even after the customer upgrades. Because each fact stores the surrogate key of the version in effect when it happened, a later change cannot rewrite it. Overwriting the attribute in place, Type 1, would silently move every past order to the customer's current plan.
The Kimball Group's definition of Type 2 names what makes this work: the dimension's primary key becomes a surrogate key, because 1 natural key now has several rows, and each row carries at least a row effective date, a row expiration date and a current row indicator.
The columns of a Type 2 dimension
customer_keycustomer_idplan_tier, countrynamevalid_fromvalid_tois_currentHow a Type 2 dimension records a change
Follow 1 customer. Customer 101, Alice, signs up on the basic plan on 2025-01-15, which creates row customer_key = 1 with valid_to NULL and is_current TRUE. On 2026-03-01 the source reports the plan is now pro. The load sets row 1's valid_to to 2026-03-01 and its flag to FALSE, then inserts row 2 with plan_tier = 'pro' and valid_from = 2026-03-01, leaving its end open.
Order 5001, $120 on 2026-02-10, was loaded while row 1 was current, so it stores customer_key = 1. Order 5002, $300 on 2026-04-02, stores customer_key = 2. Revenue by plan tier reports $120 for basic and $300 for pro, and it will report the same figures in 5 years.


Join both orders to the customer's current row instead and all $420 lands on pro. Every Type 2 design has to decide which version of the dimension a fact should see, and the answer is the version in effect when the fact happened. Capture that key once and never recompute it.
Know SCD Type 2 the way the interviewer who asks it knows it.
SCD types 0 to 7 and where Type 2 fits
The numbered SCD types are Kimball's catalogue of ways to handle a dimension attribute that changes. Type 0 keeps the original value forever, as with a customer's signup date. Type 1 overwrites, Type 2 adds a row, and Type 3 adds a column that holds the prior value, which supports a before and after comparison for 1 change but no deeper history.
The higher numbers combine the basic 3. Type 4 moves fast-changing attributes into a separate mini-dimension with its own key on the fact, and Type 5 adds the current mini-dimension key to the base dimension as a Type 1 column. Type 6 keeps Type 2 rows and adds current-value columns that are overwritten on every version, so 1 join reports either the historical or the current value. Type 7 gives the fact 2 keys: the surrogate key for the version and the durable key for a view of current rows.
In practice most warehouses mix Type 1 and Type 2 attributes in the same table, choosing per attribute. When analysts need both as-was and as-is reporting, they add Type 6 or 7. The slowly changing dimensions guide walks through each type with its own table.
SCD Type 1 vs Type 2 vs Type 3 vs Type 6
- On a change
- History kept
- Rows per member
- Fact stores
- As-was reporting
- As-is reporting
- On a changeOverwrite in place
- History keptNone
- Rows per member1
- Fact storesSurrogate key
- As-was reportingNo
- As-is reportingYes
- On a changeAdd a new row
- History keptEvery version
- Rows per member1 per version
- Fact storesThe version's key
- As-was reportingYes
- As-is reportingNeeds a 2nd join
- On a changeAdd a column
- History kept1 prior value
- Rows per member1
- Fact storesSurrogate key
- As-was reportingPrior value only
- As-is reportingYes
- On a changeNew row and overwrite
- History keptEvery version
- Rows per member1 per version
- Fact storesThe version's key
- As-was reportingYes
- As-is reportingYes, current columns
How to implement SCD Type 2 in SQL
A Type 2 load compares a staging table, 1 row per changed or new member, with the current rows of the dimension, and does 2 things: close the rows whose tracked attributes changed, and open a row for every member that has no current row. Run both statements in 1 transaction. A failure between them would leave changed customers with no current row, and every fact loaded meanwhile would lose its dimension.
The order of the statements is what keeps the insert simple. Once the changed rows are closed, the members without a current row are exactly the ones that need a new row: the customers just closed plus the ones never seen before. Unchanged customers still have their current row and are skipped, which makes the load idempotent. Run the same batch twice and the second run changes nothing.
Detect changes with IS DISTINCT FROM, not <>. In SQL, NULL <> 'pro' is NULL rather than TRUE, so a country that goes from NULL to a value, or back, never registers as a change with <>. When many columns are tracked, compare a hash of them instead: compute md5 or sha2 over the concatenated attributes in staging and store it on the row.
Deduplicate staging first. If 1 customer changed plan twice since the last load, staging holds 2 rows for it, and both statements would act twice. Either keep only the latest change per key, or, when every intermediate version matters, apply the changes in order of changed_at 1 batch at a time. Type 1 attributes such as name get their own UPDATE across all rows of the customer, so a correction reaches every version.
SCD Type 2 in SQL: the table, the 2-statement load and the MERGE
CREATE TABLE dim_customer (
customer_key BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL,
name TEXT NOT NULL,
plan_tier TEXT NOT NULL,
country TEXT NOT NULL,
valid_from TIMESTAMP NOT NULL,
valid_to TIMESTAMP,
is_current BOOLEAN NOT NULL
);
/* At most 1 current row per customer, enforced by the database */
CREATE UNIQUE INDEX dim_customer_one_current
ON dim_customer (customer_id)
WHERE is_current;PostgreSQL; the partial unique index allows many closed rows but only 1 current row per customer
BEGIN;
/* 1. Close the current row of every customer whose tracked attributes changed */
UPDATE dim_customer AS d
SET valid_to = s.changed_at,
is_current = FALSE
FROM stg_customer AS s
WHERE d.customer_id = s.customer_id
AND d.is_current
AND (d.plan_tier IS DISTINCT FROM s.plan_tier
OR d.country IS DISTINCT FROM s.country);
/* 2. Open a row for every customer left without a current row:
the ones just closed, and the ones seen for the first time */
INSERT INTO dim_customer
(customer_id, name, plan_tier, country, valid_from, valid_to, is_current)
SELECT s.customer_id, s.name, s.plan_tier, s.country, s.changed_at, NULL, TRUE
FROM stg_customer AS s
LEFT JOIN dim_customer AS d
ON d.customer_id = s.customer_id
AND d.is_current
WHERE d.customer_id IS NULL;
COMMIT;Runs on any engine with UPDATE ... FROM; changed_at is the source's change time
MERGE INTO dim_customer AS d
USING (
/* Every staged row, keyed to find its customer's current row */
SELECT s.customer_id AS merge_key, s.*
FROM stg_customer AS s
UNION ALL
/* A copy of each changed row with a NULL key: it matches nothing, so it is inserted */
SELECT NULL AS merge_key, s.*
FROM stg_customer AS s
JOIN dim_customer AS cur
ON cur.customer_id = s.customer_id AND cur.is_current
WHERE cur.plan_tier IS DISTINCT FROM s.plan_tier
OR cur.country IS DISTINCT FROM s.country
) AS src
ON d.customer_id = src.merge_key AND d.is_current
WHEN MATCHED AND (d.plan_tier IS DISTINCT FROM src.plan_tier
OR d.country IS DISTINCT FROM src.country) THEN
UPDATE SET valid_to = src.changed_at, is_current = FALSE
WHEN NOT MATCHED THEN
INSERT (customer_id, name, plan_tier, country, valid_from, valid_to, is_current)
VALUES (src.customer_id, src.name, src.plan_tier, src.country,
src.changed_at, NULL, TRUE);Runs on Snowflake, BigQuery and Databricks, and on PostgreSQL 15 or later
SCD Type 2 with a single MERGE statement
A changed customer needs 2 writes: an update to its current row and an insert of a new one. A MERGE applies at most 1 action to each target row, and the SQL standard makes it an error for 1 target row to match 2 source rows, so a plain MERGE on customer_id can close the old row or insert the new one, never both.
The standard workaround stages every changed row twice. The first copy carries the real key as merge_key, matches the current row and closes it. The second copy carries a NULL merge_key, which matches nothing, so it falls through to WHEN NOT MATCHED and is inserted as the new version. New customers appear once, with a real key and no current row, and are inserted. The Delta Lake documentation uses the same union of NULL-keyed and keyed rows for its Type 2 example.
The single statement buys atomicity on engines where multi-statement transactions are awkward, and 1 scan of the target instead of 2. It costs readability, and the matched condition must repeat the change test, or unchanged customers would be closed on every run. The same rule about duplicates applies with more force here: 2 staged rows for 1 customer make the statement fail with a cardinality error.
Point-in-time joins: facts to the right version
The fast way to join a fact to the version that was valid when it happened looks the surrogate key up once, when the fact is loaded, and stores it. Every later query is an equality join on customer_key, and a change to the customer never moves the fact.
The other way stores only the natural key on the fact and joins on a date range at query time: order_ts >= valid_from AND (order_ts < valid_to OR valid_to IS NULL). It gives the same answer when the intervals are clean. But every query pays for a range join, and a gap in the intervals silently drops facts while an overlap duplicates them. Use it to rebuild facts after a backfill, or where facts arrive before the warehouse assigns keys.
Write the range as half-open, with valid_from inclusive and valid_to exclusive. The closing row's valid_to must equal the new row's valid_from. BETWEEN valid_from AND valid_to includes both ends, so an order stamped at the exact change instant matches both versions and is counted twice. The older habit of ending a row 1 day before the next begins avoids that with dates but leaves a gap for any timestamp later that day.
Orders can arrive before their customer does. An inner join at fact load drops them, so join with a left join and give unmatched facts the key of an unknown member row, -1 by convention, then repair those keys when the dimension catches up.
Point-in-time join and as-of query in SQL
/* 1. At fact load: store the key of the version in effect when the order happened */
INSERT INTO fact_order (order_id, customer_key, order_ts, amount)
SELECT o.order_id, d.customer_key, o.order_ts, o.amount
FROM stg_order AS o
JOIN dim_customer AS d
ON d.customer_id = o.customer_id
AND o.order_ts >= d.valid_from
AND (o.order_ts < d.valid_to OR d.valid_to IS NULL);
/* 2. In analysis: an equality join, no dates needed */
SELECT d.plan_tier, SUM(f.amount) AS revenue
FROM fact_order AS f
JOIN dim_customer AS d ON d.customer_key = f.customer_key
GROUP BY d.plan_tier;
/* 3. The dimension as it stood at any moment */
SELECT customer_id, plan_tier, country
FROM dim_customer
WHERE valid_from <= TIMESTAMP '2026-02-10'
AND (valid_to > TIMESTAMP '2026-02-10' OR valid_to IS NULL);Look the key up once at load; later queries join on it by equality
Surrogate key vs natural key in the fact table
A fact that stores the natural key, customer_id, points at a customer. A fact that stores the surrogate key, customer_key, points at 1 version of that customer. In a Type 2 dimension only the second binding is stable: the natural key matches every version, so a plain join on it multiplies each fact by the number of versions the customer has, and a join to the current row reports today's attributes against yesterday's events.
That is why the foreign key from fact_order references dim_customer.customer_key, and why the relationship is many to exactly 1: many orders can share a version, and each order has exactly 1. In a star schema, every dimension key on a fact follows the same rule.
Keep the natural key on the dimension so the load can find a customer's current row, and add it to the fact as well when analysts ask for totals by current attribute. That is Kimball's Type 7: the fact carries both keys, the surrogate key joins to history and the durable key joins to a view of current rows.
Draw the foreign key from fact_order to dim_customer
fact_order references 1 version of a customer. Draw the foreign key from fact_order to dim_customer on the surrogate key, customer_key, not on the natural key, customer_id.
SCD Type 2 with dbt snapshots
In a dbt project, Type 2 is a snapshot. You point it at the source relation with a unique_key and a change detection strategy. Each dbt snapshot run then inserts new versions and closes the old ones. dbt adds 4 columns: dbt_valid_from, dbt_valid_to, dbt_scd_id, a unique key per snapshot row, and dbt_updated_at. Current rows have a NULL dbt_valid_to unless dbt_valid_to_current sets a sentinel such as 9999-12-31.
The 2 strategies differ in where the time comes from. The timestamp strategy reads an updated_at column and uses it for the validity columns, so versions start when the source says the change happened. The check strategy compares the columns in check_cols and, unless an updated_at is also configured, stamps versions with the time of the snapshot run, so a change made on Monday and captured on Wednesday starts on Wednesday. The dbt snapshot documentation recommends the timestamp strategy and warns against check_cols: all.
Since dbt 1.9 snapshots are configured in YAML rather than a Jinja snapshot block, and hard_deletes decides what a deleted source row does. ignore leaves its last version open and invalidate closes it; new_record goes further and inserts a row with dbt_is_deleted set. A snapshot captures only what it sees when it runs, so a source that changes twice between runs keeps only the second change. Schedule snapshots at least as often as the history you need, or snapshot the change stream instead of the table.
A dbt snapshot for a Type 2 customer dimension
snapshots:
- name: customers_snapshot
relation: source('crm', 'customers')
config:
unique_key: customer_id
strategy: timestamp
updated_at: updated_at
hard_deletes: invalidatesnapshots/customers_snapshot.yml, dbt 1.9 or later
SCD Type 2 in Snowflake, BigQuery and Databricks
The SQL is the same everywhere; what changes is what the engine guarantees. PostgreSQL enforces the 1-current-row rule with a partial unique index. Snowflake accepts PRIMARY KEY and UNIQUE constraints on standard tables but enforces only NOT NULL. BigQuery's primary keys are unenforced too, so on both engines the invariant exists only if a test checks it after every load.
All 3 support MERGE with IS DISTINCT FROM, so the staged union MERGE runs unchanged. Databricks also offers a declarative path in its pipelines: AUTO CDC ... STORED AS SCD TYPE 2, formerly APPLY CHANGES, takes the keys and a SEQUENCE BY column and writes __START_AT and __END_AT on every version. It orders late or out-of-order change records by the sequence column instead of arrival time.
On columnar engines, nearly every read of the dimension filters on the natural key or the current flag, so cluster or partition by 1 of them. Keep the closed history in the same table. Splitting current and history into 2 tables makes current reads cheaper but turns every point-in-time query into a union.
Deletes, late-arriving changes and reruns
Handle members deleted at the source on purpose, because leaving the current row open reports a closed account as active. Closing the row by setting valid_to keeps the history and ends it at the deletion. Adding a final version flagged as deleted says when it happened as a row of its own, which is what dbt's new_record option does. Physically deleting dimension rows breaks every fact that points at them and is almost never right.
A change that arrives late, dated before the current row began, cannot simply be appended. It belongs inside an existing interval, so the load has to split that version in 2 and move every fact loaded since the backdated change to the new key. Most teams accept load-time validity for this reason: valid_from records when the warehouse learned of the change. Where business-time accuracy matters, rebuild the affected member's versions from the ordered change log and re-key its facts in 1 transaction.
Reruns must be harmless. A load that is safe to run twice compares with IS DISTINCT FROM so an unchanged row is never versioned, takes valid_from from the source's change time rather than the clock, and refuses a batch whose change times are older than the current rows it would close.
How to test a Type 2 dimension: overlaps, gaps and current rows
The versions of 1 member must tile time: each interval starts exactly where the previous one ends, and only the latest row is open. A broken load shows up as 1 of 3 faults. A missing close leaves 2 current rows. When the close and the insert are stamped with different times, they leave an overlap or a gap, and a failed insert leaves no current row at all.
Each fault changes what a range join returns. An order at a moment covered by 2 versions joins 2 rows, so its $300 counts as $600. An order in a gap joins 0 rows and drops out of an inner join. Only back-to-back intervals join it exactly 1 time.


3 queries catch all of it, and each should return no rows after every load. Current rows: group by customer_id where is_current and keep the groups with COUNT(*) > 1. Overlaps and gaps: order each member's versions by valid_from and compare every valid_to with the LEAD(valid_from) of the row after it, which must be equal. Orphans: count fact rows whose customer_key has no dimension row. In dbt these become tests that run with every build; in PostgreSQL the partial unique index blocks the first fault before it is written.
When Type 2 is the wrong choice: row growth
A Type 2 dimension holds 1 row per member plus 1 per change, so its size is set by the fastest-changing tracked attribute. Take 10 million customers kept for 3 years. Track only attributes that change about once a year, such as plan tier, and the table holds 40 million rows. Track last_login_at as Type 2 as well and it changes daily, so the table grows to 10.96 billion rows, about 274 times larger, almost all of it login noise.
The fix is to take high-churn values out of the dimension. An event such as a login belongs in a fact table of its own. A banded, frequently changing profile such as an age band or a usage band belongs in a Type 4 mini-dimension, whose key sits on the fact beside the customer key. What stays in the Type 2 dimension should be attributes that change rarely and change the meaning of past facts.
Type 1 is the right answer for corrections. A misspelled name or a mistyped postcode was never true, so versioning it creates history nobody wants and a row count that grows with every data quality fix. Decide per attribute, write the decision down, and track as Type 2 only the attributes the business reports history on.
SCD Type 2 in data engineering interviews
SCD Type 2 is one of the topics data modeling interviewers return to most, because 1 question exposes whether a candidate has run a warehouse. Expect to write the 2-statement load live, then the point-in-time join, and expect a follow-up on why the fact stores customer_key and not customer_id. Strong answers mention the transaction, IS DISTINCT FROM, half-open intervals and the test for 2 current rows without being asked.
Design questions push further: a dimension that grew to hundreds of millions of rows for a few million customers, a change that arrived a week late, a source that deletes rows, or a business that wants revenue by both the plan at purchase and the plan today. Each is answered by a technique on this page, from moving churn out of the dimension to Type 6 and Type 7.
The SCD interview questions collect these as they are asked in real interviews and answer each one in full, while the data modeling interview questions cover the rest of the round. To practise drawing and loading a Type 2 model, work through the data modeling practice problems, and read the data modeling interview round guide for how the round is run.
SCD Type 2 FAQ
What is SCD Type 2?+
How do you implement SCD Type 2 in SQL?+
How do you query a Type 2 dimension as of a specific date?+
Should fact tables reference the natural key or surrogate key?+
How does dbt implement SCD Type 2?+
When should I use Type 2 vs Type 1?+
What are the SCD types?+
Should the open end date be NULL or 9999-12-31?+
The candidate who gets the offer
- 01
Reading a solution is not the same as writing one
Every engineer who has frozen on a query they had read a dozen times knows the gap. The only preparation that closes it is producing the answer yourself, under time, before the interview does it for you
- 02
76% of hiring managers reject on the coding task, not the resume
From HackerRank's 2024 Developer Skills Report. Candidates who look strong on paper still fail the live screen if they haven't done timed, executable practice
- 03
The round is won on tradeoffs, not on the diagram
Grain, star vs snowflake, SCD type, conformed dimensions, late-arriving data. Modeling under live pushback is what separates the bands, and it is the half almost nobody rehearses