dbt Snapshots: Tracking Slowly Changing Dimensions Over Time
Most source tables only show you the current state of the world โ a customerโs row shows their address right now, with no memory of what it was six months ago. Thatโs fine until someone asks โwhich region was this customer in when they placed order #4821,โ and the honest answer is that the data needed to answer that question was overwritten the moment the customer updated their address. dbt snapshots solve this by capturing point-in-time history of a tableโs changes, implementing whatโs classically called a Type 2 Slowly Changing Dimension.
The Problem Snapshots Solve
Consider a customers source table that gets overwritten in place whenever a customer updates their profile:
Day 1: customer_id=42, region='West', updated_at='2026-01-01'Day 45: customer_id=42, region='East', updated_at='2026-02-15' โ same row, overwrittenWithout snapshots, only the current state (region='East') is ever visible. Any historical analysis โ โwhat percentage of West-region orders came from customers now in the East regionโ โ is simply unanswerable, because the history was destroyed the moment the row was updated.
How a Snapshot Works
A snapshot is defined as a SQL select statement, run on a schedule, that dbt compares against the last captured version of each row. If a row has changed, dbt closes out the old version and inserts a new one โ never deleting history.
-- snapshots/customers_snapshot.sql{% snapshot customers_snapshot %}
{{ config( target_schema='snapshots', unique_key='customer_id', strategy='timestamp', updated_at='updated_at', )}}
select * from {{ source('raw', 'customers') }}
{% endsnapshot %}dbt snapshotEach run compares the source tableโs current state against whatโs already captured, and the resulting snapshots.customers_snapshot table gains four extra columns dbt manages automatically:
| Column | Meaning |
|---|---|
dbt_scd_id | A unique identifier for this specific version of the row |
dbt_valid_from | When this version became the current one |
dbt_valid_to | When this version stopped being current (null if still current) |
| Original columns | Every column from the source, exactly as it was at that point in time |
Querying Historical State
Once snapshotted, answering โwhat was true at a point in timeโ becomes a straightforward filter:
select *from {{ ref('customers_snapshot') }}where customer_id = 42 and '2026-02-01' between dbt_valid_from and coalesce(dbt_valid_to, current_timestamp)This returns exactly the version of customer 42โs row that was current on February 1st โ region='West', in the earlier example โ even though the source table itself has long since been overwritten with the current East-region value.
Two Snapshot Strategies: timestamp vs check
Timestamp strategy (shown above) relies on a reliable updated_at column that changes whenever the source row changes. Itโs the preferred strategy whenever such a column exists and is trustworthy.
Check strategy compares specific columns directly, useful when thereโs no reliable updated-at timestamp to lean on.
{{ config( target_schema='snapshots', unique_key='customer_id', strategy='check', check_cols=['region', 'email', 'plan_tier'], )}}
select * from {{ source('raw', 'customers') }}Here, dbt compares the listed columns between runs and creates a new snapshot version only if one of them actually changed โ useful when a source tableโs updated_at gets bumped on every sync regardless of whether meaningful data changed, which would otherwise create a new snapshot version on every single run even when nothing relevant actually changed.
Snapshot Frequency and Granularity
Snapshots only capture the state that existed at the time the snapshot ran โ if a customerโs region changes twice between two scheduled snapshot runs, only the second change is captured; the intermediate state is lost forever. This means snapshot frequency needs to match how granular your historical analysis actually needs to be.
# Typical production cadence โ run before the main model builddbt snapshotdbt runA snapshot run every hour captures much finer-grained history than one run nightly โ the tradeoff is purely about how much historical resolution your analysis actually requires versus the compute cost of running snapshots more frequently.
Referencing Snapshots From Models
Once created, a snapshot is referenced with ref() exactly like any other model โ the fact that itโs a snapshot rather than a regular model is invisible to the calling SQL.
select o.order_id, o.order_date, s.region as customer_region_at_order_timefrom {{ ref('stg_orders') }} as oleft join {{ ref('customers_snapshot') }} as s on o.customer_id = s.customer_id and o.order_date between s.dbt_valid_from and coalesce(s.dbt_valid_to, current_timestamp)This join correctly attributes each historical order to the customerโs region as it was at the time of that order, not their current region โ the entire point of capturing history in the first place.
Common Mistakes
Choosing timestamp strategy without verifying the updated_at column is trustworthy. If the sourceโs updated_at doesnโt actually change reliably whenever a row changes, the timestamp strategy will silently miss updates โ check strategy is the safer fallback in that case.
Snapshotting too infrequently for the analysis thatโs needed. If stakeholders need to reconstruct daily-granularity history but snapshots only run weekly, most of that history is simply unrecoverable โ thereโs no way to snapshot retroactively.
Forgetting that deleted source rows arenโt automatically marked invalid without the invalidate_hard_deletes config โ without it, a row deleted from the source can appear to remain โcurrentโ in the snapshot forever.
{{ config( ..., invalidate_hard_deletes=True, )}}Summary
| Concept | Purpose |
|---|---|
{% snapshot %} block | Defines what to capture and how to detect changes |
strategy='timestamp' | Detects changes via a reliable updated_at column |
strategy='check' | Detects changes by comparing specific column values |
dbt_valid_from / dbt_valid_to | The time window each captured version was current |
Snapshots are the mechanism that lets a warehouse answer โwhat was true then,โ not just โwhatโs true nowโ โ essential for any analysis that depends on historical accuracy rather than the current, overwritten state of mutable source data.