Build reliable incremental dbt models: choose the right unique_key and strategy (append, merge, delete+insert), handle late-arriving data and out-of-order events, write a safe is_incremental filter, and design the full-refresh fallback — so the model is idempotent from day one.
How this skill is triggered — by the user, by Claude, or both
Slash command
/analytics-engineering:incremental-model-patternsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
**Purpose:** Produce incremental dbt models that are correct under rerun, late-data, and full-refresh scenarios. Used by `analytics-engineer` (primary) and `data-quality-testing-engineer` (testing incremental correctness).
Purpose: Produce incremental dbt models that are correct under rerun, late-data, and full-refresh scenarios. Used by analytics-engineer (primary) and data-quality-testing-engineer (testing incremental correctness).
| Strategy | When to use | Key constraint |
|---|---|---|
append_only (no deduplication) | Source is truly append-only and no row is ever updated | Reruns will create duplicate rows — only safe if you NEVER rerun on overlapping data |
merge (default for most warehouses) | Source rows can be updated (e.g. order status changes), or you need upsert behaviour | Requires a unique_key; most expensive at scale |
delete+insert (BigQuery, Snowflake) | Bulk-replace a time-partition or date-range in one operation | Requires a partition_by clause; excellent for date-partitioned fact tables with late updates |
insert_overwrite (Spark/Databricks) | Partition-level replacement on Spark | Partition column must be in the partition_by block |
Recommendation for most fact tables: merge with a unique_key on the natural event grain. Upgrade to delete+insert only when merge cost becomes prohibitive.
The unique_key is the identifier dbt uses to decide whether to UPDATE (existing row) or INSERT (new row).
Rules:
event_id or a composite of (session_id, event_sequence_number).entity_id, valid_from).Common mistakes:
| Mistake | Symptom | Fix |
|---|---|---|
unique_key is too narrow (e.g. just date) | Entire day's data is merged into one row | Add the natural event grain to the key |
unique_key is too wide (includes a mutable field) | Rows are never matched; duplicates accumulate | Remove mutable fields from the key |
No unique_key with merge strategy | dbt falls back to append; duplicates on rerun | Always specify unique_key with merge |
The is_incremental() macro filters source data to only the new or updated rows when running incrementally. It is the most failure-prone part of the model.
Standard pattern (timestamp-based, with a lookback window):
-- models/marts/fct_orders.sql
{{
config(
materialized='incremental',
unique_key='order_id',
on_schema_change='append_new_columns'
)
}}
select
order_id,
customer_id,
order_status,
order_amount,
created_at,
updated_at
from {{ ref('stg_orders') }}
{% if is_incremental() %}
-- Lookback window handles late-arriving records and CDC lag
-- Adjust the interval to 2x the expected source lag
where updated_at >= (
select dateadd(hour, -6, max(updated_at)) from {{ this }}
)
{% endif %}
Why the lookback window:
Without it, a source record updated 2 hours after the last dbt run is missed because updated_at > max(updated_at) is exactly false at the boundary. A 6–24 hour lookback is a safe default; tune it to 2 × max observed source lag.
For event tables without an updated_at field (true append-only sources):
{% if is_incremental() %}
where event_timestamp > (select max(event_timestamp) from {{ this }})
{% endif %}
This is safe only when the source guarantees no late arrivals and no retroactive updates.
Late-arriving data — records that arrive in the source after the model has already processed their time window — are the most common cause of incremental model data quality failures.
Detection: run a row-count comparison between the incremental model and a reference full-scan query over the same time window. Any shortfall indicates missed late arrivals.
Strategies:
| Approach | How | Trade-off |
|---|---|---|
| Lookback window (recommended) | Extend is_incremental() filter back N hours/days | Slightly more data processed per run; simplest to reason about |
| Source watermark table | Write the last successful run timestamp to a watermark table; read it in the filter | More precise; adds operational complexity |
| Partition reprocessing | delete+insert strategy reprocesses the last N date partitions each run | Correct for date-partitioned sources; expensive if partitions are large |
Every incremental model must be safe to full-refresh. This means:
dbt build --full-refresh is tested in the CI pipeline at least on a subset of the model's data.On schema change:
Add on_schema_change='append_new_columns' to the config so that new columns added to the model don't require an out-of-band table migration. For breaking schema changes (column rename, type change), a full-refresh is required; plan accordingly.
unique_key on a mutable column — the key changes, so rows are never matched; duplicates accumulate silently until a full-refresh.is_incremental() filter references a column not in the target table — the first run (full build) passes, but incremental runs fail because the subquery hits the not-yet-existing column. Always reference columns that exist in both the source and the target model.npx claudepluginhub mcorbett51090/ravenclaude --plugin analytics-engineeringGuides completion of development work by verifying tests, detecting environment, and presenting structured options for merge, PR, or cleanup.
Enforces test-driven development: write failing test first, then minimal code to pass. Use when implementing features or bugfixes.
Guides creation and editing of skills using test-driven development with pressure scenarios and subagents to verify agent compliance.