SNAPSHOTS
Your source tables only know the present — when a customer's status changes, the old value is gone. A snapshot records those changes as they happen, building a type-2 slowly-changing-dimension table so you can always ask "what did this row look like last March?"
Snapshots in DVT are pure dbt: same files, same strategies, same meta columns. If you have dbt snapshots today, they run unchanged with dvt snapshot.
YOUR FIRST SNAPSHOT
A snapshot is a .sql file in your snapshots/ directory: a {% snapshot %} block wrapping a config and a plain select. Here is a complete, working one:
-- snapshots/customers_snapshot.sql
{% snapshot customers_snapshot %}
{{
config(
target_schema='snapshots',
unique_key='customer_id',
strategy='timestamp',
updated_at='updated_at'
)
}}
select * from {{ source('analytics', 'customers') }}
{% endsnapshot %}Run it:
$ dvt snapshot 1 of 1 START snapshot snapshots.customers_snapshot ............... [RUN] 1 of 1 OK snapshotted snapshots.customers_snapshot ............... [SUCCESS 42 in 1.32s] Finished running 1 snapshot in 1.32s. Completed successfully Done. PASS=1 WARN=0 ERROR=0 SKIP=0 TOTAL=1
The first run creates snapshots.customers_snapshot on your default target and copies every row in. Every run after that compares the source against what the snapshot already knows and records only what changed. Schedule it — daily is typical — and history accumulates on its own.
WHAT LANDS IN THE TABLE
The snapshot table has every column your select produced, plus four meta columns the engine maintains for you:
| COLUMN | MEANING |
|---|---|
| dbt_valid_from | When this version of the row became true. Set when the row is first seen or a change is detected. |
| dbt_valid_to | When this version stopped being true. null while the row is still current — closed out with a timestamp when a newer version arrives. |
| dbt_scd_id | A surrogate key unique to each row version. Handy as the grain of downstream history models. |
| dbt_updated_at | The updated_at value (timestamp strategy) or detection time (check strategy) for this version. |
So when customer 1 upgrades from trial to paid, the snapshot ends up holding both versions — the old one closed out, the new one open:
customer_id | status | updated_at | dbt_valid_from | dbt_valid_to ------------+--------+------------+----------------+-------------- 1 | trial | 2026-01-02 | 2026-01-02 | 2026-01-15 ← history 1 | paid | 2026-01-15 | 2026-01-15 | null ← current
Downstream models ref() a snapshot like any model. The current state of the world is simply the rows that were never closed out:
-- models/silver/current_customers.sql
select *
from {{ ref('customers_snapshot') }}
where dbt_valid_to is nullTHE TIMESTAMP STRATEGY
The example above uses strategy='timestamp': you name a column (updated_at) that reliably moves forward whenever the row changes, and the engine records a new version whenever it sees a fresher value for a given unique_key. It is the cheapest strategy — one column comparison — and the one to prefer whenever your source has a trustworthy last-modified column.
"Trustworthy" is the operative word: if an upstream process updates rows without touching updated_at, those changes are invisible to this strategy. That is what check is for.
THE CHECK STRATEGY
No reliable timestamp? strategy='check' compares the actual column values between runs and records a version whenever any of the listed check_cols differ:
-- snapshots/products_snapshot.sql
{% snapshot products_snapshot %}
{{
config(
target_schema='snapshots',
unique_key='product_id',
strategy='check',
check_cols=['price', 'status']
)
}}
select * from {{ source('analytics', 'products') }}
{% endsnapshot %}A change to price or status creates a new version; a change to any other column does not. check_cols='all' watches every column — convenient, but the comparison cost grows with table width, so on wide tables name the columns you actually care about.
YAML-DEFINED SNAPSHOTS
The engine also supports dbt's newer YAML shape: define the snapshot in a .yml file and point it straight at a relation — no {% snapshot %} block, no SQL file at all for the simple select-everything case:
# snapshots/orders_snapshot.yml
snapshots:
- name: orders_snapshot
relation: source('analytics', 'orders')
config:
schema: snapshots
unique_key: order_id
strategy: timestamp
updated_at: updated_atBoth shapes are first-class in DVT — including in dvt flip-target-to, which recognizes block snapshots and bare-query YAML-defined snapshots alike (more on that below). Pick one style per project and stay consistent.
RUNNING SNAPSHOTS
dvt snapshot # every snapshot in the project
dvt snapshot --select customers_snapshot # just this one
dvt snapshot --select tag:nightly # by tag
dvt snapshot --empty # zero-row dry run: builds/validates
# the table, records nothing
dvt build # seeds + models + snapshots + tests,
# DAG orderSelection is dbt's node-selection syntax, the same one dvt run uses. dvt build places snapshots into the full DAG, so a snapshot that reads a model runs after that model builds. Like every graph verb, dvt snapshot starts from a passing dvt parse — a broken project refuses with the parse error, never a half-run.
One honest warning about state: a snapshot table is accumulated history, so the engine deliberately protects it — --full-refresh does not rebuild snapshots the way it rebuilds incremental models. Dropping a snapshot table is a manual act, and it destroys history that cannot be recomputed from the source. Treat snapshot tables like production data, because they are.
WHERE SNAPSHOTS RUN: ALWAYS THE DEFAULT TARGET
Snapshots are a pure pass-through to the engine: they execute on your default target, in its native SQL dialect, through the official adapter. Only models can be federated in DVT — f_table and f_incremental are model materializations, and there is no federated snapshot.
config(target=...) has no effect on a snapshot. The target= pin only applies to federated models — the engine runs snapshots on the default target whatever the config says. DVT says this out loud rather than half-honoring it: dvt flip-target-to warns by name on any snapshot carrying a target pin instead of silently ignoring it.
So what if the data you want history for lives on another engine? Land it first with a federated model, then snapshot the landed copy — federation does the cross-engine move, and the snapshot stays pure dbt:
-- models/bronze/bronze_crm_customers.sql
-- Oracle data, landed on the default target by federation
{{ config(materialized='f_table') }}
select customer_id, status, updated_at
from {{ source('crm', 'customers') }}
-- snapshots/crm_customers_snapshot.sql
-- snapshots the landed copy — runs after the model in dvt build
{% snapshot crm_customers_snapshot %}
{{
config(
target_schema='snapshots',
unique_key='customer_id',
strategy='timestamp',
updated_at='updated_at'
)
}}
select * from {{ ref('bronze_crm_customers') }}
{% endsnapshot %}In plain dbtyou'd need a separate EL tool and a scheduler handshake to snapshot data your warehouse can't reach. In DVT the landing model and the snapshot live in one project and one dvt build, in DAG order.
SNAPSHOTS SURVIVE A TARGET FLIP
Snapshot SQL is written in your default target's dialect — which used to make it the thing a warehouse migration silently broke. dvt flip-target-to treats snapshots as a first-class path class, read from your project's snapshot-paths: when you flip the default target, every snapshot is transpiled to the new engine's dialect exactly like a model, with the same per-file report.
The transpile is surgical. A {% snapshot %}block's own Jinja — the tags, the strategy, unique_key, updated_atconfig — carries semantics, not dialect, so it comes back byte-for-byte. Only the SQL body inside the block is translated, and the file is refused untouched rather than written if the Jinja doesn't survive intact. YAML-defined snapshots, whose .sql is a bare query, translate like a plain model file. And the flip report tallies per class — a project can flip every model and still be broken by one untranslatable snapshot, so snapshots get their own line in the summary.
In plain dbt, changing warehouses means rewriting snapshot SQL by hand — and nothing tells you which files need it; they just fail on the next run. DVT transpiles them with the flip and names anything it couldn't carry, per file, with the line.
One thing the flip deliberately does notdo: move your data. The history rows accumulated on the old engine stay on the old engine — a flip rewrites code, and migrating a snapshot table's accumulated state is a decision you make explicitly, not a side effect. Plan that copy as its own step before pointing your schedule at the new target.
HONEST NOTES ON ENGINE DIFFERENCES
Snapshot semantics are identical everywhere — the same strategies, the same meta columns. The physics follow the adapter, and a few differences are worth knowing:
- ▸Each adapter implements the snapshot materialization with its own SQL — merge on engines that have it, delete+insert where they don't. Same result, different plans and different costs.
- ▸Timestamp comparison follows the engine's timestamp semantics. Mixed timezones or truncated precision in updated_at can hide or duplicate changes — keep the column UTC and at consistent precision, especially if the source engine differs from the target.
- ▸check strategy costs one column-by-column comparison per run. Columnar warehouses shrug at it; row stores feel check_cols='all' on wide tables.
- ▸Rows deleted from the source are ignored by default — the snapshot keeps the last version open. The hard_deletes config ('invalidate' or 'new_record') changes that, at the cost of a full existence check per run.
CONFIG REFERENCE
| CONFIG | REQUIRED | DESCRIPTION |
|---|---|---|
| unique_key | yes | Column (or expression) identifying a row across runs. The grain of the snapshot. |
| strategy | yes | 'timestamp' or 'check' — how changes are detected. |
| updated_at | no | timestamp strategy: the column that moves forward when the row changes. Required for that strategy. |
| check_cols | no | check strategy: list of columns to compare, or 'all'. Required for that strategy. |
| target_schema | no | Schema the snapshot table lands in (classic block spelling). YAML-defined snapshots use schema:. Defaults to the target's schema. |
| target_database | no | Database override for the snapshot table (YAML spelling: database:). |
| hard_deletes | no | 'ignore' (default), 'invalidate' (close out rows deleted from the source), or 'new_record' (add a deletion-marker version). |
| invalidate_hard_deletes | no | Legacy boolean spelling of hard_deletes='invalidate'. Still accepted; prefer hard_deletes. |
| dbt_valid_to_current | no | Value to use instead of null in dbt_valid_to for current rows (e.g. '9999-12-31' for range joins). |
| snapshot_meta_column_names | no | Rename the four meta columns to match your conventions. |
| tags | no | Tags for selection (dvt snapshot --select tag:...). |
| COMMAND / FLAG | WHAT IT DOES |
|---|---|
| dvt snapshot | Run every snapshot in the project on the default target. |
| -s, --select | dbt node selection: names, tag:, path:, graph operators. |
| --exclude | Drop nodes from the selection (same syntax). |
| --empty / --no-empty | Run against zero rows — a schema-only dry run that validates the snapshot without recording anything. |
| --target | Run against another profile output for this invocation. |
| --threads | Override the profile's thread count. |
| --vars | Supply project variables as YAML. |
| dvt build | Seeds + models + snapshots + tests in DAG order — snapshots run after the models they ref. |