Load when task involves dbt snapshots, SCD Type 2, or tracking data changes over time. Covers strategy selection, column casing, verification, and common pitfalls.
Scanned 9/3/2026
Install to Claude Code
npx -y skills add SignalPilot-Labs/SignalPilot --skill dbt-snapshots --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Dbt Snapshots?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/signalpilot-labs-dbt-snapshots-signalpilot)More formats (shields.io, HTML) on the badges page.
---
name: dbt-snapshots
description: "Load when task involves dbt snapshots, SCD Type 2, or tracking data changes over time. Covers strategy selection, column casing, verification, and common pitfalls."
disable-model-invocation: false
allowed-tools: Bash(dbt *)
---
# dbt Snapshots - SCD Type 2
## 1. File Location
Place snapshot files in `snapshots/`, NOT `models/`.
Verify `snapshot-paths: ["snapshots"]` exists in `dbt_project.yml`.
## 2. Strategy Selection
**Choose the strategy BEFORE writing the snapshot file.**
Query the candidate timestamp column first:
```sql
SELECT COUNT(DISTINCT updated_at), MIN(updated_at), MAX(updated_at)
FROM my_source_table
```
- If `COUNT(DISTINCT updated_at) = 1` or all values are frozen (e.g., `1980-01-01`), the column is unreliable - a frozen timestamp means `dbt snapshot` will never detect changes.
- If values vary and reflect real mutation times, `strategy='timestamp'` is valid.
| Condition | Strategy |
|-----------|----------|
| `updated_at` has changing, meaningful values | `strategy='timestamp'`, `updated_at='UPDATED_AT'` |
| `updated_at` frozen at a single constant value (COUNT(DISTINCT)=1) | `strategy='check'`, `check_cols='all'` - a column stuck at one value will never detect changes |
| No `updated_at` column at all | `strategy='check'`, `check_cols='all'` |
Default to `strategy='check'` with `check_cols='all'` when uncertain - it always works.
Do NOT rationalize a frozen timestamp as valid business data. `1980-01-01` on every row is a data quality artifact, not a real date.
## 3. Column Casing
Snapshot `unique_key`, `updated_at`, and `check_cols` must match the EXACT case of the source columns.
Query column names before writing the config:
```sql
DESCRIBE my_source_table
```
Wrong: `unique_key='id'` when the source column is `ID`.
Right: `unique_key='ID'`.
DuckDB and Snowflake snapshot configs are case-sensitive. A casing mismatch silently produces zero change detection.
## 4. Writing the Snapshot Block
SELECT explicit columns - not `SELECT *`. Include only business columns.
Use raw source column names - do NOT alias them (e.g., `SELECT ID`, not
`SELECT ID AS HOST_ID`). The snapshot output columns must match the source
for `unique_key` and `check_cols` to work.
```sql
{% snapshot snap__employees %}
{{
config(
target_schema='main',
unique_key='EMPLOYEE_ID',
strategy='check',
check_cols='all'
)
}}
SELECT EMPLOYEE_ID, DEPARTMENT, TITLE, SALARY
FROM {{ source('hr', 'employees') }}
{% endsnapshot %}
```
Set `target_schema` to the project's default schema (`'main'` for DuckDB).
## 5. Data Type Awareness
Query actual column values before writing predicates:
```sql
SELECT DISTINCT IS_ACTIVE FROM my_source_table LIMIT 10
```
- If values are `'t'`/`'f'` (strings), compare with `= 't'`, NOT `= TRUE`.
- If values are `true`/`false` (booleans), compare with `= TRUE`.
Getting this wrong silently filters to zero rows.
## 6. Running Snapshots
Run `dbt snapshot` - not `dbt run`. `dbt run` ignores snapshot files.
If the task involves tracking changes over time, run `dbt snapshot` once per mutation phase. A single run captures only the initial state.
## 7. Verification
After `dbt snapshot`, verify history was captured:
```sql
SELECT COUNT(*) AS total, COUNT(DISTINCT EMPLOYEE_ID) AS distinct_keys
FROM main.snap__employees
```
- If `total = distinct_keys`, zero changes were detected - the strategy or config is wrong. Go back to Section 2.
- Query `dbt_valid_to`: if ALL values are NULL, only the initial load exists - no mutations were tracked.
Also verify SCD2 columns exist: `dbt_valid_from`, `dbt_valid_to`, `dbt_scd_id`, `dbt_updated_at`.
## 8. Downstream Models
Reference snapshots like any model: `{{ ref('snap__employees') }}`.
- For **current state** (latest version of each row): `WHERE dbt_valid_to IS NULL`.
- For **full change history** (e.g., "who has ever been X"): use ALL rows. Use `LAG()` over `dbt_valid_from` to detect status transitions.
- A snapshot table can hold history loaded before your run, even when `snapshots/` is absent. Probe it BEFORE `dbt snapshot`: `SELECT COUNT(*), COUNT(DISTINCT dbt_valid_from) FROM main.snap__employees`. Multiple `dbt_valid_from` values are pre-existing history to build on, NOT the Section 7 zero-change failure - compute metrics over all versions.
- For a "first time in state X" metric (not a transition count), use `MIN(dbt_valid_from)` filtered to state X. A row already in state X at the earliest version still has a first-observed time, so do NOT require an earlier different-state version.
## Rules
- NEVER use `run_in_background` or `&` with `dbt snapshot` - it holds a write lock.
- Do NOT modify `.yml` files for snapshots - snapshot config lives in the `.sql` block.
- A single `dbt snapshot` run with no prior state produces only the initial load, not history. This is expected - history builds across multiple runs with source mutations between them.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!