Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when writing or fixing a dbt incremental model over an append-only event or fact table — makes the model idempotent so a rerun does not double-count, by declaring a unique_key, gating the source scan with is_incremental() plus a lookback window for late-arriving rows, choosing merge vs delete+insert on purpose, and naming when a full refresh is required.
.claude/skills/dbt-incremental-model-strategy/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 400% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 415% | 0% |
| case-02 | ✗→✗ | = Same ✗ | 418% | 0% |
| case-03 | ✗→✗ | = Same ✗ | 403% | 0% |
| case-04 | ✗→✗ | = Same ✗ | 395% | 0% |
Turns "make this dbt model incremental" into a model that is idempotent — running it twice produces the same table, never a doubled one. The trap: a model marked materialized='incremental' still re-scans and re-inserts rows on every run unless you gate the scan and tell dbt how to reconcile the new rows against what is already there. Miss either and you either double-count on rerun or silently drop rows that arrive late. This skill encodes the four decisions that make an incremental model correct.
Any time you author or repair a dbt model that materializes incrementally over an append-heavy source — event streams, click logs, order lines, transaction facts — and the goal is that scheduled reruns (and backfills) don't corrupt the row counts.
unique_key. Without it, an incremental run appends every selected source row,so any row that was already loaded on a prior run lands a second time → duplicates and inflated counts. Set unique_key to the column (or list of columns) that uniquely identifies a row in the destination grain. This is what lets dbt replace-on-match instead of blindly appending. If the grain is a composite (e.g. order_id + line_no), pass the list, not a single column.
is_incremental() AND a lookback window. Wrap the incrementalfilter in an {% if is_incremental() %} block so a full run (first build or --full-refresh) scans everything and an incremental run scans only recent rows. The naive filter is a strict watermark — where event_at > (select max(event_at) from {{ this }}) — which drops late-arriving rows: any event whose event_at is older than the current max but that landed in the source after the last run is never picked up. Use an overlapping lookback window instead: sql {% if is_incremental() %} where event_at >= (select coalesce(max(event_at), '1900-01-01') from {{ this }})
{% endif %} The window re-scans a bounded tail so late rows are caught. Reprocessing that tail is only safe because decision 1 (unique_key) makes the re-inserted rows replace rather than duplicate — the two decisions are a pair. Size the window to the worst-case source lateness, not to convenience.
incremental_strategy on purpose — merge vs delete+insert.merge (default on Snowflake/BigQuery/Databricks): needs a unique_key; issues a singleMERGE that updates matched rows and inserts new ones. Prefer it when the adapter supports it and you have a clean unique key. Add merge_update_columns if only some columns should be overwritten on a match.
delete+insert: deletes the destination rows whose key is in the incoming batch, theninserts the batch. Choose it when the adapter has no efficient MERGE, when the key is composite and MERGE is awkward, or when you want the whole matched partition rebuilt. On partition-oriented warehouses, insert_overwrite by partition is the same idea at partition grain. State which one you picked and why — don't leave it to the adapter default and hope.
if you change the transformation logic, past rows keep their old values until the table is rebuilt from scratch. Rebuild with dbt run --full-refresh --select my_model (or set config(full_refresh=true) to force it, false to protect a huge table from an accidental one). Call this out whenever the SELECT logic changes, and flag models where a full refresh is prohibitively expensive so backfills are planned, not stumbled into.
sql{{ config( materialized='incremental', unique_key='event_id', incremental_strategy='merge', on_schema_change='append_new_columns' ) }} select event_id, user_id, event_at, payload from {{ source('app', 'events') }} {% if is_incremental() %} where event_at >= (select coalesce(max(event_at), '1900-01-01') from {{ this }}) - interval '3 days' {% endif %}
Read it as: full build scans all history; every scheduled run scans a 3-day tail and MERGEs it on event_id, so late rows are captured and reruns are idempotent. Drop any one of the four decisions and this breaks.
materialized='incremental' with no unique_key and a bare is_incremental() guard is the common first draft. It looks incremental and passes a first run. It fails the moment a run overlaps previously-loaded data (a retry, an overlapping schedule, a late row inside the window) — those rows append a second time. Idempotency is the whole point of the pattern; the four decisions above are what deliver it.
Other measured skills in the registry, with their headline benchmark lift.