How to Implement dbt SCD2 Snapshots (Slowly Changing Dimensions)
dbt snapshots implement Slowly Changing Dimension Type 2 by automatically versioning rows when watched columns change - no hand-written surrogate key logic. You define a snapshot block, run it on a schedule, and dbt handles the valid_from/to bookkeeping.
What SCD2 means and why it matters
A Slowly Changing Dimension Type 2 (SCD2) preserves the full history of a row by
keeping every version rather than overwriting in place. When alice@old.com
changes to alice@new.com, you end up with two rows:
| id | dbt_valid_from | dbt_valid_to | |
|---|---|---|---|
| 1 | alice@old.com | 2024-01-01 | 2024-06-01 |
| 1 | alice@new.com | 2024-06-01 | NULL |
dbt_valid_to = NULL means "current." Without SCD2, historical reports
retroactively pick up today's dimension values - every time a customer changes
their email, your old revenue reports silently show the new address.
Create the snapshot block
Snapshots live in dbt_project/snapshots/. Create
customers_snapshot.sql:
{% snapshot customers_snapshot %}
{{ config(
target_schema='dbt_demo',
unique_key='id',
strategy='check',
check_cols=['name', 'email']
) }}
SELECT id, name, email
FROM {{ source('raw', 'customers') }}
{% endsnapshot %}
Key settings:
- target_schema - the schema dbt writes the snapshot table into
- unique_key - the natural key used to match existing rows
- strategy: check - dbt compares the listed check_cols on every run; a change in any of them closes the old row and inserts a new one
- strategy: timestamp - the alternative when you have a reliable updated_at column; cheaper than comparing N columns
Run the snapshot across a simulated change
cd dbt_project
# First run: captures all rows with dbt_valid_to = NULL
dbt snapshot --profiles-dir ..
# Simulate a dimension change
psql -h localhost -U postgres -d app -c \
"UPDATE customers SET email='alice@new-domain.com' WHERE id=1"
# Second run: dbt detects the change, closes the old row, inserts the new version
dbt snapshot --profiles-dir ..
# Inspect the result
psql -h localhost -U postgres -d app -c \
"SELECT id, email, dbt_valid_from, dbt_valid_to
FROM dbt_demo.customers_snapshot
ORDER BY id, dbt_valid_from"
After the second run you see two rows for id=1 - one with dbt_valid_to set
(closed, historical) and one with dbt_valid_to = NULL (current).
The four metadata columns dbt adds
dbt appends these to every snapshot table automatically:
dbt_scd_id- unique surrogate key for each version rowdbt_updated_at- when this version was captureddbt_valid_from- start of the validity windowdbt_valid_to- end of the validity window (NULL = still current)
Production patterns
- Schedule snapshots hourly or daily -
dbt snapshotis idempotent; run it from your orchestrator (Airflow, dbt Cloud, Dagster) on a tight cadence for dimensions that change frequently. - Prefer
timestampstrategy when you have a trustworthyupdated_atcolumn - it only reads the changed rows rather than comparing every column for every row. - Pair with CDC (Change Data Capture via Debezium or Fivetran) for sub-minute change capture when hourly snapshots are too slow.
- Downstream joins - join fact tables to the snapshot using
WHERE fact.event_at BETWEEN dbt_valid_from AND COALESCE(dbt_valid_to, NOW())to get the dimension value that was current at the time of the event.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Writing a {% snapshot %} block with the check strategy and check_cols
- Running dbt snapshot twice across a simulated dimension change to produce versioned rows
- Querying dbt_valid_from/dbt_valid_to to retrieve point-in-time dimension values
FAQ
What is the difference between dbt snapshot check and timestamp strategy?
The check strategy compares specific columns (check_cols) on every run and closes a row if any watched column changes - good when there is no updated_at column. The timestamp strategy uses an updated_at column to detect changes and is more efficient because dbt only reprocesses rows newer than the last run.
How do I query historical dimension values with dbt snapshots?
Join your fact table to the snapshot with WHERE fact.event_at BETWEEN snapshot.dbt_valid_from AND COALESCE(snapshot.dbt_valid_to, NOW()). This returns the dimension row that was current at the time of each fact event rather than today's value.
When should I use SCD2 instead of just keeping the latest row?
Use SCD2 whenever you need accurate historical reporting - for example, revenue by the customer segment they belonged to at purchase time, not their current segment. If all your reports are real-time dashboards with no historical comparison, a simple overwrite (SCD1) is simpler.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →