dbt Incremental Models in Production: unique_key, Merge, and Late Arrivals
How incremental dbt models actually write: unique_key as merge grain, is_incremental filters, lookback windows, and the late-arrival cases that silently drop rows.
By Dinesh Chandra
Table of contents
A full-refresh table is honest. Incremental is a write contract: which rows you read, which rows you match, and what happens on a retry. Most broken incrementals I have inherited compiled fine and still dropped late updates or doubled the day.
If you are still setting up staging and marts, start with the
first dbt project. This post assumes
you already ref() a staging model and want the fact to stop rebuilding
the warehouse every run.
flowchart LR
stg[Staging / source] --> filt{is_incremental filter}
filt -->|first run or full-refresh| all[All qualifying rows]
filt -->|incremental run| win[Lookback window]
all --> write[Write strategy]
win --> write
write --> merge[MERGE on unique_key]
write --> append[Append if no key]
The filter decides the read. unique_key decides the write. They are not the same knob.
Incremental is a write pattern
materialized='incremental' does not mean “only new rows.” It means:
on the first run (and on --full-refresh) dbt builds the relation like
a table. On later runs it adds or merges whatever your SELECT
returns.
That SELECT is your job. dbt will not infer a watermark. If you omit
the is_incremental() filter, every run scans the entire upstream
model and then merges it. You paid for incremental ceremony and still
did a full rebuild — plus merge overhead.
-- models/marts/fct_orders.sql
{{ config(
materialized='incremental',
unique_key='order_id',
incremental_strategy='merge',
incremental_predicates=[
"DBT_INTERNAL_DEST.ordered_at >= dateadd('day', -7, current_date())"
],
on_schema_change='fail'
) }}
with src as (
select
order_id,
customer_id,
status,
amount,
ordered_at,
updated_at
from {{ ref('stg_orders') }}
{% if is_incremental() %}
where updated_at >= dateadd(
'hour',
-6,
(select coalesce(max(updated_at), '1970-01-01') from {{ this }})
)
{% endif %}
)
select * from src
Three pieces, three jobs:
unique_key— the grain of the MERGE match.is_incremental()filter — what you read from upstream.incremental_predicates— what slice of the target the engine is allowed to scan during the merge.
Leave any one out and you get a different incident.
unique_key is the merge grain
On Snowflake, BigQuery, and Databricks, incremental_strategy='merge'
emits a MERGE keyed on unique_key. One row per key in the SELECT.
Two source rows with the same order_id in one incremental batch is
undefined: warehouses pick a winner or the merge fails. Dedup in a CTE
first if the extract can replay.
Without unique_key, most adapters append. A retry of the same
window inserts yesterday again. That is the same class of bug as an
Airflow load that only INSERTs — idempotency is a write pattern, not
a scheduler setting.
Composite keys are normal:
{{ config(
materialized='incremental',
unique_key=['account_id', 'event_date'],
incremental_strategy='merge'
) }}
The key must be unique in the model you are writing, not “unique
enough in the source today.” Put unique + not_null tests on those
columns. The
Python quality checks
post is the same rule on the pandas path: grain first, then volume.
A unique_key that is not the business grain (for example id from
an extract that reissues ids) will overwrite the wrong row. The merge
succeeds. The dashboard is wrong.
The is_incremental filter
is_incremental() is false on:
- The first build of the relation
--full-refresh- A full refresh forced by
on_schema_changein some setups
It is true on a normal subsequent dbt run. The filter belongs on the
source of rows, usually {{ ref('stg_…') }} or a narrow
intermediate — not as a HAVING after you already scanned everything.
{% if is_incremental() %}
where updated_at >= (
select max(updated_at) from {{ this }}
)
{% endif %}
That snippet is the tutorial version. It is also how late arrivals disappear.
updated_at must move when the row you care about changes. If the
source only stamps ingested_at on first landing, a corrected amount
never re-enters the window. Use the column the producer actually
bumps, or you are filtering on the wrong clock.
Do not wrap the filter column in a function
(where date(updated_at) = current_date()). Same as any warehouse
query: you kill pruning. Compare with a range.
Lookback and late arrivals
A late arrival is a row whose event time is old but whose
updated_at (or landing time) is new — or worse, a row that shows up
after you already advanced the watermark past its event time.
Example: the fact watermarks on max(ordered_at). An order from
Tuesday is corrected on Thursday. Tuesday is behind the watermark.
The merge never sees it.
Example two: you watermark on max(updated_at) with no lookback.
The source writes in small batches. Clock skew, a delayed CDC flush,
or a transaction that commits late can land with an updated_at
slightly behind the max you already stored. Those rows are invisible
forever unless you full-refresh.
The production habit is a lookback: re-read a window behind the
max, and let unique_key overwrite.
{% if is_incremental() %}
where updated_at >= dateadd(
'hour',
-6,
(select max(updated_at) from {{ this }})
)
{% endif %}
Six hours is a starting point, not a law. Size it from how late the source actually is — CDC lag, a twice-daily dump, a partner SFTP that arrives at 04:00 with yesterday’s corrections. Write the reason in a model comment. Changing the lookback in Slack after an incident is how the next incident looks identical.
If the source is partitioned by date and late data only revisits
recent partitions, prefer a partition replace
(delete+insert or insert_overwrite) on ordered_at::date plus a
two- or three-day lookback. Merge on a skinny key is right when rows
update in place across many dates. Overwrite is right when a day is
the unit of replay.
{{ config(
materialized='incremental',
incremental_strategy='delete+insert',
unique_key='order_date'
) }}
select
order_date,
count(*) as orders,
sum(amount) as revenue
from {{ ref('stg_orders') }}
{% if is_incremental() %}
where order_date >= dateadd('day', -2, (select max(order_date) from {{ this }}))
{% endif %}
group by 1
unique_key here is the partition you delete before insert, not a
degenerate merge key. A retry of the same two days replaces those
days. It does not append a third copy.
Predicates: do not merge-scan the whole fact
A merge that matches on order_id but scans five years of the target
is an incremental model with a table-sized bill. incremental_predicates
(adapter support varies; Snowflake and Databricks are the usual
homes) restrict the target scan.
The predicate must be true for every row you might update. If a late arrival can touch a row older than seven days, a seven-day predicate will skip it and the merge will look successful. Align the predicate with the lookback, or with a hard business rule (“we never correct orders older than 14 days”).
{{ config(
materialized='incremental',
unique_key='order_id',
incremental_strategy='merge',
incremental_predicates=[
"DBT_INTERNAL_DEST.ordered_at >= dateadd('day', -14, current_date())"
]
) }}
Cluster or partition the target on the same column you predicate. Otherwise the predicate is a residual filter after a full read.
Schema change and full refresh
on_schema_change='fail' is the default I want on facts. A new
column should be a PR, not a silent append of NULLs or a surprise
rebuild in prod.
When you change unique_key or the grain, do not merge. Full
refresh (or rebuild into a new relation and swap). Merging a new
grain onto old keys leaves orphans and duplicates that tests will
not explain.
--full-refresh in prod is a load-bearing operation: warehouse time,
downstream readers, and any extra incremental models that ref()
this one. Schedule it. Do not discover it mid-incident because
someone added a column with sync_all_columns on a 2B-row fact.
Two runs in CI, not one
A single dbt build on an empty PR schema only tests the full-build
path. Incremental bugs live on the second run.
dbt build --select fct_orders
dbt build --select fct_orders
The second invocation should be a no-op on unchanged fixtures, or a merge of the rows you deliberately updated. Assert:
- Row count did not double
- A fixture row with a bumped
updated_atchanged - A fixture row outside the lookback did not change
Warehouse tests on grain still belong on the table. Incremental Python checks on the slice you are about to load will not see last month’s leftover duplicates — pair the layers.
flowchart TD
src[stg_orders] --> win[Lookback on updated_at]
win --> dedup[One row per unique_key]
dedup --> merge[MERGE into fct_orders]
merge --> tests[unique + not_null on key]
late[Late CDC / corrected order] --> win
Late rows re-enter through the lookback. unique_key overwrites. Tests catch a broken grain after the write.
Strategies, briefly
| Strategy | Use when |
|---|---|
merge |
Rows update in place; you have a stable unique key |
delete+insert |
You can name a replaceable partition (day, hour, batch id) |
append |
Immutable events, and you will never retry the same window |
insert_overwrite |
Partitioned tables where a partition is the unit of replay |
append plus retries is how facts grow a second Monday. If you
cannot name a key or a partition, you do not have an incremental
design yet. Keep it a table.
Microbatch (event-time buckets, newer dbt) is a structured lookback.
It does not free you from unique_key or from late data that
arrives after the bucket closed. Same contract, smaller default
window.
Pitfalls
- Filter on
max(event_time)with no lookback. Corrections and late CDC miss the cut. Watermark the change column and look back. - No
unique_keyon merge. You appended. A retry doubled the window. - Two rows per key in one batch. Dedup with a window
(
row_number— see SQL window functions) before the merge. is_incremental()filter omitted. Full upstream scan every run. The logs say incremental. The warehouse knows better.- Predicate tighter than the lookback. Merge skips the row you just read. Silent miss.
- Changing grain without full refresh. Old keys remain. New keys collide. Tests flap for a week.
on_schema_change='sync_all_columns'on a large fact. Prod rebuilds itself because someone added a comment column.
Production checklist
unique_keymatches the business grain;unique+not_nulltests on those columns.is_incremental()filter is a range on a column that moves when the row changes.- Lookback is documented with why that duration exists.
incremental_predicates(if used) are at least as wide as the lookback.- Strategy matches the replay unit: merge for in-place updates, delete+insert / overwrite for partitions.
on_schema_change='fail'on facts.- CI runs the model twice. Second run must not duplicate rows.
- Full refresh is a planned job when grain or key changes — not an accident in the deploy.
FAQ
Why did yesterday’s rows double after a retry?
No unique_key, or strategy is append. Incremental then inserts
whatever the SELECT returns. Add a key and merge, or delete+insert
the partition you are rebuilding.
Why is a corrected order from last week still wrong?
Your filter is updated_at >= max(updated_at) with no lookback, or
you watermarked ordered_at. Late and corrected rows sit behind
that max. Widen the window and merge on order_id.
Can I skip unique_key if the source is append-only?
Only if a retry cannot produce the same window twice. Airflow
retries. You will backfill. Prefer a key or a replaceable
partition anyway.
Should CI run --full-refresh only?
That tests the first-build path. Incremental bugs are on the
second run. Build twice against fixtures.
When do I full-refresh prod?
When the grain, unique_key, or a non-additive schema change
makes the existing table a lie. Schedule it. Do not merge onto a
broken relation and hope tests converge.
The first project post gets you staging, marts, and tests. Incremental is the next contract: filter what you read, key what you write, look back far enough that late data can still land. If any of those three are folklore, keep the model a table until they are not.
Enjoyed this post?
Get the next one in your inbox — one email a week, no spam.
Newsletter signup is not live yet. Use the contact form if you want to be notified.