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.
Scanned 9/23/2026
npx -y skills add mcorbett51090/RavenClaude --skill incremental-model-patterns --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Incremental Model Patterns?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/mcorbett51090-incremental-model-patterns)More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.
---
name: incremental-model-patterns
description: "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."
---
# Skill: incremental-model-patterns
**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).
## When to use
- A fact table is large enough that a full rebuild is expensive or violates SLA (general rule: > 10M rows or > 30 minutes for a full table scan).
- A source grows mostly via append — new events, new orders, new log lines.
- The model must remain idempotent: running it twice must produce the same result as running it once.
---
## Step 1: Choose the incremental strategy
| 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.
---
## Step 2: Define the unique key correctly
The `unique_key` is the identifier dbt uses to decide whether to UPDATE (existing row) or INSERT (new row).
**Rules:**
1. The unique key must identify a row's grain — the combination of attributes that make one row distinct from all others.
2. For event tables, this is usually `event_id` or a composite of (`session_id`, `event_sequence_number`).
3. For slowly changing dimensions in an incremental model, it may be (`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` |
---
## Step 3: Write the is_incremental filter
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):**
```sql
-- 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):**
```sql
{% 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.
---
## Step 4: Handle late-arriving data
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 |
---
## Step 5: Full-refresh fallback
Every incremental model must be safe to full-refresh. This means:
1. The full-refresh produces the same result as N sequential incremental runs over all history.
2. There is no state outside the model's own table that would cause a full-refresh to differ from the incremental result.
3. `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.
---
## Pitfalls
- **Missing lookback window** — the most common cause of silent data loss in incremental models on sources with CDC or batch lag.
- **`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.
- **No full-refresh test in CI** — the model appears to work incrementally but is broken for full-refresh; this surfaces as a disaster during a first production deployment or a schema migration.
- **Incremental model used for a dimension** — dimension tables with history management (SCD2) need a different pattern (dbt snapshots), not a raw incremental model; conflating them produces incorrect history.
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!