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.

Last updated: Proudly published by: Jeff Wahl16 min read

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_key
Surrogate key, 1 per version, the key every fact row stores
customer_id
Natural key from the source, shared by every version of 1 customer
plan_tier, country
Type 2 attributes, where any change opens a new row
name
Type 1 attribute, corrected in place on every version
valid_from
Start of the version's validity, inclusive
valid_to
End of validity, exclusive, NULL while the row is current
is_current
TRUE on exactly 1 row per customer_id

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

Timeline for customer_id 101: row key 1 (basic) ends at the plan change where row key 2 (pro) begins with an open end; order 5001 for $120 stores key 1 and order 5002 for $300 stores key 2Timeline for customer_id 101: row key 1 (basic) ends at the plan change where row key 2 (pro) begins with an open end; order 5001 for $120 stores key 1 and order 5002 for $300 stores key 2

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.

Prepare for the interview
01 / Open invite
02min.

Know SCD Type 2 the way the interviewer who asks it knows it.

a SCD Type 2 query, the same shape a screen would give you.
The diff against expected. Where ties broke. What you missed.
sandbox
1fact_orders
2 order_id bigint PK
3 customer_sk bigint FK
4 order_date date SCD2
5
Execute your solution0.4s avg.
DatabricksInterview question
Solve a SCD Type 2 problem

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

Type 1
  • On a changeOverwrite in place
  • History keptNone
  • Rows per member1
  • Fact storesSurrogate key
  • As-was reportingNo
  • As-is reportingYes
Type 2
  • 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
Type 3
  • On a changeAdd a column
  • History kept1 prior value
  • Rows per member1
  • Fact storesSurrogate key
  • As-was reportingPrior value only
  • As-is reportingYes
Type 6
  • 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

SQLdim_customer.sql
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

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

Try itConnect fact_order to the Type 2 dimension
drag • connect • edit

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.

Loading canvas...

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: invalidate

snapshots/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 versions of the same 2-row history with 1 order at the same moment: where the rows overlap the order joins 2 rows and counts $600; where a gap separates them it joins 0 rows and counts $0; tiled back to back it joins 1 row and counts $3003 versions of the same 2-row history with 1 order at the same moment: where the rows overlap the order joins 2 rows and counts $600; where a gap separates them it joins 0 rows and counts $0; tiled back to back it joins 1 row and counts $300

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?+
SCD Type 2 is the slowly changing dimension technique that adds a new row whenever a tracked attribute changes, instead of overwriting the old value. Each row is 1 version of the member, with its own surrogate key and a validity interval in valid_from and valid_to, usually with an is_current flag beside them. Every past state stays queryable, and every fact keeps the version it happened under.
How do you implement SCD Type 2 in SQL?+
Inside 1 transaction, run 2 statements. First, an UPDATE closes the current row of every member whose tracked attributes changed, setting valid_to to the change time and is_current to false. Then an INSERT opens a new row for every staged member that has no current row, which covers both the members just closed and brand new ones. Compare attributes with IS DISTINCT FROM so a change to or from NULL is detected.
How do you query a Type 2 dimension as of a specific date?+
Filter on the validity interval: valid_from <= the date and (valid_to > the date or valid_to IS NULL). With half-open intervals, where 1 row's valid_to equals the next row's valid_from, this returns exactly 1 row per member for any moment. Avoid BETWEEN, which includes both ends and returns 2 rows at the exact change instant.
Should fact tables reference the natural key or surrogate key?+
The surrogate key, looked up once when the fact is loaded. It binds the fact to the version in effect when the event happened, so later changes never move it, and every query joins on a plain equality. Keep the natural key in the dimension (or also on the fact, the Kimball Type 7 pattern) for reporting against current values.
How does dbt implement SCD Type 2?+
With snapshots. You declare a unique_key and a strategy, timestamp (driven by an updated_at column, the recommended choice) or check (a list of columns to compare). Each dbt snapshot run inserts changed rows and closes the old ones. dbt keeps the interval in dbt_valid_from and dbt_valid_to, and also writes dbt_scd_id and dbt_updated_at on every row. Since dbt 1.9 snapshots are configured in YAML, and hard_deletes controls what happens when a source row disappears.
When should I use Type 2 vs Type 1?+
Use Type 2 for attributes whose history changes the meaning of past facts: plan tier, price list, sales territory, segment. Use Type 1 for corrections, such as a misspelled name, where the old value was simply wrong and keeping it would only add noise. Most dimensions mix both, tracking a few attributes as Type 2 and overwriting the rest.
What are the SCD types?+
Kimball's catalogue runs from Type 0 to Type 7. Type 0 keeps the original value, Type 1 overwrites, Type 2 adds a row, Type 3 adds a column for the prior value, Type 4 moves volatile attributes to a mini-dimension, Type 5 adds a current mini-dimension key to the base dimension, Type 6 adds current-value columns to Type 2 rows, and Type 7 gives the fact both a surrogate key and a durable key.
Should the open end date be NULL or 9999-12-31?+
Either works if the whole team uses 1 convention. NULL says honestly that the end is unknown but needs an OR valid_to IS NULL in every range filter. A far-future date such as 9999-12-31 lets every filter use a plain comparison and suits engines that prune on ranges. dbt uses NULL by default and accepts a sentinel through dbt_valid_to_current.
02 / Why practice

The candidate who gets the offer

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

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

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