Slowly Changing Dimension Type 2: How SCD2 Works in a Data Warehouse

Learn how SCD Type 2 tracks dimension history in a data warehouse, with SQL examples for loading, querying, and point-in-time lookups.

SCD Type 2 Explained

A customer moves from the South region to the North region. Every order they placed before the move still happened in the South. If the warehouse overwrites the region, last year's sales report changes shape overnight. Slowly Changing Dimension Type 2 (SCD2) is the modeling pattern that keeps the old row, adds a new one, and lets each fact join to the version that was true when it happened.

What is a slowly changing dimension?

A slowly changing dimension is a dimension table (customer, product, employee, location) whose descriptive attributes change over time, and the SCD type is the rule for what the warehouse does when that happens. The three basic answers are overwrite the value, add a new row, or add a new column.

In dimensional modeling, facts are the events you measure (an order, a shipment, a payment) and dimensions describe the who, what, and where around those events. Dimension attributes change slowly compared to facts: a customer changes address a few times in a lifetime, while orders arrive every minute. The SCD type is the warehouse owner's choice of how to respond to changed descriptions of entities such as Employee, Customer, Product, Supplier, and Location.

A common example is an employee's region or a customer's address. Sales credited to the old region should stay there. A report on "sales by current region" is a different question, and the SCD type decides whether the table can answer one, the other, or both.

What are SCD types 0 through 7?

Kimball defines eight types. Types 0, 1, 2, and 3 are the base techniques (keep the original, overwrite, add a row, add a column). Type 4 splits volatile attributes into a mini-dimension, and types 5, 6, and 7 are hybrids built on top of type 2.

Type

Kimball name

What happens on a change

Good for

0

Retain original

The value never changes, so facts always group by the original value

Original credit score, durable IDs, most date attributes

1

Overwrite

The old value is replaced in place. No history

Typo fixes, attributes where only the latest value matters

2

Add new row

A new row is inserted with the updated values and a new surrogate key. The old row stays

Full history of selected attributes

3

Add new field

The old value moves to an alternate column and the regular column is overwritten

One or two "alternate realities", such as old vs new product category. It does not scale beyond a few choices

4

Add mini-dimension

A group of fast-changing attributes is split into a separate mini-dimension with its own key in the fact table

Large dimensions with a few volatile attributes (age band, income band)

5

Mini-dimension plus type 1 outrigger

Type 4 plus a type 1 pointer from the base dimension to the current mini-dimension row

Current mini-dimension values without going through the fact table

6

Type 1 attributes in a type 2 dimension

Type 2 rows also carry the current value, which is updated on all prior rows for the same durable key

Filter by the value at event time or by the current value in one table

7

Dual type 1 and type 2 dimensions

The fact table carries two foreign keys: the type 2 surrogate key and the durable key that points at the current row

Same as type 6, with the current view served by a separate view over the dimension

Type 4 in Kimball's numbering is a mini-dimension. Some tools and blog posts use "type 4" to mean a separate history table next to a current table. That is a useful pattern, but it is a different thing from what Kimball numbered.

How does a Type 2 slowly changing dimension work?

When a tracked attribute changes, a Type 2 dimension keeps the old row untouched, inserts a new row with the new values and a new surrogate key, and marks the old row as expired. Kimball's minimum is three extra columns: a row effective date, a row expiration date, and a current-row indicator.

Two keys make this work. The natural key (customer_id from the source system) is the same on every version and is what Kimball calls the glue that holds the versions together. The surrogate key (customer_sk) is a warehouse-assigned integer that is different on every version. Fact rows store the surrogate key, so an order placed in February keeps pointing at the February version of the customer forever.

Two rules keep the validity windows clean. The expiration timestamp of one version must equal the effective timestamp of the next version, with no gaps and no overlaps. And the current version has either a NULL expiration or a far-future date such as 9999-12-31, depending on the tool. The current flag exists so that "give me today's customers" is a simple filter instead of a date comparison.

What columns does an SCD2 table need?

An SCD2 table needs the natural key, a per-version surrogate key, the tracked attributes, an effective-from timestamp, an effective-to timestamp, and a current flag. A hash of the tracked attributes is optional but makes change detection a single comparison.

Column

Type

Role

customer_sk

BIGINT

Surrogate key. Unique per version. Fact tables store this

customer_id

INTEGER

Natural key from the source. Same on every version

customer_name, region

VARCHAR

The attributes you chose to track

attr_hash

VARCHAR

md5 of the tracked attributes joined with a separator. Changes only when a tracked attribute changes

valid_from

TIMESTAMP

When this version became true

valid_to

TIMESTAMP

When this version stopped being true. NULL while current

is_current

BOOLEAN

TRUE on exactly one version per customer_id

Only hash the attributes you want to version. If the source table has a last_login column that changes daily and you include it in the hash, you get a new customer version every day. Microsoft's worked example uses the same shape with different names: SalesRepID as the surrogate key, RepSourceID as the natural key, plus StartDate, EndDate, IsCurrent, and Hash.

How do you implement SCD Type 2 in SQL?

The portable SCD2 load is two statements inside one transaction. First, close the current version of any customer whose hash differs from the staged row. Second, insert a new version for every staged row that now has no current match, which covers both brand-new customers and the ones just closed.

The staging table holds one row per customer per load with the values as they are now and the time they changed:

CREATE TABLE dim_customer (
customer_sk BIGINT,
customer_id INTEGER,
customer_name VARCHAR,
region VARCHAR,
attr_hash VARCHAR,
valid_from TIMESTAMP,
valid_to TIMESTAMP,
is_current BOOLEAN
);

CREATE TABLE stg_customer (
customer_id INTEGER,
customer_name VARCHAR,
region VARCHAR,
changed_at TIMESTAMP
);

Step one closes the changed versions. Unchanged customers match on hash and are skipped:

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.attr_hash <> md5(concat_ws('|', s.customer_name, s.region));

Step two inserts a new version for anything without a current row. The surrogate key continues from the current maximum:

INSERT INTO dim_customer (
customer_sk, customer_id, customer_name, region,
attr_hash, valid_from, valid_to, is_current
)
WITH max_sk AS (
SELECT coalesce(max(customer_sk), 0) AS value
FROM dim_customer
),
rows_to_insert AS (
SELECT s.*
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
)
SELECT
row_number() OVER (ORDER BY s.customer_id) + max_sk.value AS customer_sk,
s.customer_id,
s.customer_name,
s.region,
md5(concat_ws('|', s.customer_name, s.region)) AS attr_hash,
s.changed_at AS valid_from,
NULL AS valid_to,
TRUE AS is_current
FROM rows_to_insert AS s
CROSS JOIN max_sk;

To see it work, load three customers on 2024-01-01, then a second batch on 2024-03-10 where Ana (101) moved from South to North, Bruno (102) is unchanged, and Diego (104) is new. Carla (103) is absent from the second batch:

INSERT INTO stg_customer VALUES
(101, 'Ana', 'South', TIMESTAMP '2024-01-01 09:00:00'),
(102, 'Bruno', 'North', TIMESTAMP '2024-01-01 09:00:00'),
(103, 'Carla', 'South', TIMESTAMP '2024-01-01 09:00:00');
-- run the two steps

TRUNCATE stg_customer;
INSERT INTO stg_customer VALUES
(101, 'Ana', 'North', TIMESTAMP '2024-03-10 14:30:00'),
(102, 'Bruno', 'North', TIMESTAMP '2024-03-10 14:30:00'),
(104, 'Diego', 'West', TIMESTAMP '2024-03-10 14:30:00');
-- run the two steps again

The dimension after the second load, from DuckDB 1.5.5:

+-------------+-------------+---------------+--------+---------------------+---------------------+------------+
| customer_sk | customer_id | customer_name | region | valid_from | valid_to | is_current |
+-------------+-------------+---------------+--------+---------------------+---------------------+------------+
| 1 | 101 | Ana | South | 2024-01-01 09:00:00 | 2024-03-10 14:30:00 | false |
| 4 | 101 | Ana | North | 2024-03-10 14:30:00 | NULL | true |
| 2 | 102 | Bruno | North | 2024-01-01 09:00:00 | NULL | true |
| 3 | 103 | Carla | South | 2024-01-01 09:00:00 | NULL | true |
| 5 | 104 | Diego | West | 2024-03-10 14:30:00 | NULL | true |
+-------------+-------------+---------------+--------+---------------------+---------------------+------------+

Four source customers became five dimension rows. Ana has two versions, the South one closed at exactly the timestamp the North one opened. Bruno's hash matched, so nothing happened to him. Carla was missing from the second batch and is still current, because this pattern treats an absent row as "no news". Deletes are covered below.

Two things to watch in this pattern. It expects one row per customer_id in staging per run. If your staging table carries several versions of the same key in one load (a CDC feed does this), keep only the latest version per key before running it, or loop the two steps in changed_at order. And the UPDATE and INSERT must run in the same transaction, so a failure between them cannot leave a customer with zero current rows.

How do you query the current record or a point in time with SCD2?

The current state is a filter on the flag. A point-in-time lookup uses a half-open interval: the as-of timestamp is greater than or equal to valid_from and strictly less than valid_to, with NULL valid_to counting as open.

SELECT customer_id, customer_name, region
FROM dim_customer
WHERE is_current
ORDER BY customer_id;

+-------------+---------------+--------+
| customer_id | customer_name | region |
+-------------+---------------+--------+
| 101 | Ana | North |
| 102 | Bruno | North |
| 103 | Carla | South |
| 104 | Diego | West |
+-------------+---------------+--------+

The as-of query. Half-open matters at the boundary: asking for exactly 2024-03-10 14:30:00 returns the North row, because that instant belongs to the new version and not the old one:

SELECT customer_id, region, customer_sk, valid_from, valid_to
FROM dim_customer
WHERE customer_id = 101
AND TIMESTAMP '2024-02-15 00:00:00' >= valid_from
AND (TIMESTAMP '2024-02-15 00:00:00' < valid_to OR valid_to IS NULL);

+-------------+--------+-------------+---------------------+---------------------+
| customer_id | region | customer_sk | valid_from | valid_to |
+-------------+--------+-------------+---------------------+---------------------+
| 101 | South | 1 | 2024-01-01 09:00:00 | 2024-03-10 14:30:00 |
+-------------+--------+-------------+---------------------+---------------------+

The same interval logic is how facts get their surrogate key at load time. Two orders from Ana, one in February and one in April, land on different customer versions:

SELECT f.order_id, f.ordered_at, d.region, d.customer_sk
FROM fact_order AS f
JOIN dim_customer AS d
ON d.customer_id = f.customer_id
AND f.ordered_at >= d.valid_from
AND (f.ordered_at < d.valid_to OR d.valid_to IS NULL)
ORDER BY f.order_id;

+----------+---------------------+--------+-------------+
| order_id | ordered_at | region | customer_sk |
+----------+---------------------+--------+-------------+
| 1001 | 2024-02-01 10:00:00 | South | 1 |
| 1002 | 2024-04-01 16:45:00 | North | 4 |
+----------+---------------------+--------+-------------+

Once customer_sk is stored on the fact row, reports join on the surrogate key alone and never touch the date columns. February's order stays in the South forever, with no date math at query time.

Where do the changes come from: dbt snapshots, CDC, or a managed history mode?

An SCD2 transform can only version the changes that reached the warehouse. If the source is read once a day and a customer changes region twice in that day, the dimension gets one version, not two. So the capture method decides how faithful the history is, before any SQL runs.

There are three ways the changes arrive:

Approach

How it sees changes

What it misses

Example

Batch comparison

Reads the source table on a schedule and diffs it against the last version

Any state that appears and disappears between two reads. It only works if the dimension changes slower than you read it

dbt snapshots, dlt scd2 on a full extract

Cursor sync

Reads rows with updated_at above the last seen value

Deletes, because a deleted row has no updated_at to find, and intermediate states between runs

Incremental loads on an updated_at column

Log-based CDC

Reads the database transaction log, so every insert, update, and delete is recorded in order

Nothing at the source. Delivery can still be batch

PostgreSQL WAL, MySQL binlog

dbt snapshots are a batch-based approach to change data capture meant to run between hourly and daily. That is enough when a daily grain of history is enough. Fivetran History Mode shows the same split. When the source has logs, it captures all changes between syncs. When it does not, a value that changed from 10 to 15 to 20 between two syncs is recorded only as 20.

Change Data Capture (CDC) reads the log the database already writes for its own recovery. In PostgreSQL that is the write-ahead log (WAL), and a CDC process reads it through a replication slot and gets each operation, in order, with the row state. For updates and deletes to carry the full before and after row, the table needs REPLICA IDENTITY FULL, which the Erathos PostgreSQL connector docs list along with wal_level = logical and a max_slot_wal_keep_size limit so a stalled slot cannot fill the disk.

With Erathos, the Partial Versioned sync type appends every record version to the destination and marks which one is the most recent. That gives the two-step load above a staging table that already contains all the versions, with one caveat from the previous section: several versions of the same key can land in one run, so the load processes them in changed_at order. The MySQL setup for the same thing is in the MySQL CDC to BigQuery guide.

How do dbt, dlt, Fivetran, Databricks, and Airbyte name SCD2 columns?

The tools below use the same three ideas (valid-from, valid-to, current marker) under different column names and with different defaults for the open row and for deletes.

Tool

Change detection

Validity columns

Open-row marker

Deletes

dbt snapshots

timestamp strategy on an updated_at column, or check strategy on a list of check_cols

dbt_valid_from, dbt_valid_to, plus dbt_scd_id and dbt_updated_at

dbt_valid_to is NULL, or a future date via dbt_valid_to_current

hard_deletes: ignore (default), invalidate, or new_record, which adds a row with dbt_is_deleted = True

dlt scd2 merge

Row hash over all columns stored in _dlt_id, or your own hash via row_version_column_name

_dlt_valid_from, _dlt_valid_to

NULL by default, or a high timestamp via active_record_timestamp

A row absent from a full extract is retired. On incremental extracts, merge_key controls which absent rows count as deleted

Fivetran History Mode

Every source version observed, log-based where the source has logs

_fivetran_start, _fivetran_end

_fivetran_active = TRUE and _fivetran_end = 9999-12-31T23:59:59.999Z (a 2038 date on MySQL destinations)

Row closed at the delete timestamp minus one millisecond

Databricks AUTO CDC

CDC feed with KEYS and SEQUENCE BY, STORED AS SCD TYPE 2

__START_AT, __END_AT, same type as the SEQUENCE BY field

__END_AT open

APPLY AS DELETE WHEN. Tombstones stay in the Delta table for a while and a view filters them

Airbyte

Legacy normalization produced SCD tables. Destinations V2 deprecates that normalization

Old SCD tables are left in place but no longer updated

Two defaults matter more than the names. dbt recommends the timestamp strategy where possible because it needs one column and copes better when the source adds or removes columns. dlt's default hash covers every column in the resource, so an unstable column (an array whose order changes, for example) produces a false version unless you supply your own hash over the fields you care about.

How should an SCD2 pipeline handle deletes and late-arriving changes?

A delete needs a declared policy, because the two-step load above cannot see it. Late or out-of-order changes need a sequence column, because closing the current row at an earlier timestamp than its valid_from produces a negative-length version.

For deletes there are three policies, and the dbt hard_deletes options map to them one-to-one:

  • Ignore. The row stays current forever. This is the dbt default, so a deleted customer still shows up in "today's customers".
  • Invalidate. Set valid_to and is_current = FALSE on the current row, so the customer has no current version. Fivetran History Mode does this and closes the row at the delete time.
  • New record. Insert a version with a deleted flag set, so a query for "who was a customer on this date" still has a row to find, and a restore later becomes one more version.

Detecting the delete in the first place needs either a full extract to compare against (dlt retires any row absent from a full extract) or a log-based source where the delete is an event. With a cursor sync there is nothing to detect.

For late-arriving changes, Databricks requires a SEQUENCE BY column and uses it to order events that arrive out of order. The equivalent in the two-step pattern is to process staging rows in changed_at order and to split an existing window when a change lands inside it, which is more work than the plain close-and-insert. If your source is CDC and the log is read in order, out-of-order events are rare and the ordering is already in the feed.

When is Type 1 enough, and what does Type 2 cost?

Type 1 is enough when no report needs the value as it was at the time of the event. Type 2 is needed when facts must stay attached to the version that was true when they happened, and it costs one extra row per tracked change plus a more involved load and join.

The cost is directional rather than a fixed ratio. Every tracked change adds a row, so a dimension with volatile attributes can grow fast, and a hash that includes the wrong columns turns a slowly changing dimension into a fast one. The same effect shows up on a bill. In Fivetran History Mode, every changed or inserted source record creates a destination row that counts toward paid MAR (monthly active rows, its pricing unit).

A useful middle path is Kimball's Type 6: keep Type 2 rows and add a current-value column that gets overwritten on all versions of the same customer. One table then answers "sales by region at order time" and "sales by the customer's region today" without a second dimension.

Erathos loads PostgreSQL and MySQL changes from the transaction log into BigQuery, Databricks, or Redshift, with the versioned sync type that keeps every record version for an SCD2 load like the one above. Try Erathos free for 14 days.