6.6 Incremental models¶
Concept¶
A table model is rebuilt from scratch on every run: all 1,000 orders, every time. That's fine for 1,000 rows. For a billion, it's hours of work to change yesterday's few thousand.
An incremental model builds the full table once. After that, each run only selects the
new or changed rows and merges them in: update the rows that are already there, insert
the new ones. It's the MERGE (upsert) from MERGE & upserts and
Transform to Silver, with dbt writing the statement for you.
You tell dbt three things:
| Setting | Meaning |
|---|---|
materialized='incremental' |
build once, then add to it |
unique_key='order_id' |
how to recognise a row that's already in the table |
a filter inside {% if is_incremental() %} |
which rows count as "new" on later runs |
Lab¶
Continue in ~/work/my_files/dbt/shop.
1 · The incremental model¶
New file models/marts/fct_orders.sql, a fact table of orders
(Dimensional modeling):
{{ config(
materialized='incremental',
unique_key='order_id',
incremental_strategy='merge'
) }}
select
order_id,
customer_id,
channel,
order_date,
status,
current_timestamp() as loaded_at
from {{ ref('stg_orders') }}
{% if is_incremental() %}
-- only the last 3 days of orders already in the table, and anything newer
where order_date >= (select date_sub(max(order_date), 3) from {{ this }})
{% endif %}
{{ config(…) }}sets the materialization in the model, as in Models, ref() and materializations.incremental_strategy='merge': apply the changes withMERGE(Iceberg supports it).current_timestamp() as loaded_at: when the row was written. It shows which rows each run touched.{% if … %} … {% endif %}: a Jinja if.{{ … }}prints a value;{% … %}is a statement (if, for, set) and prints nothing itself. Thewhereline is only part of the SQL when the condition is true.is_incremental()is true when the table already exists and this is a normal run. On the first run, it's false: no filter, every row.{{ this }}: the model's own table,gold.fct_orders.date_sub(d, 3): the date 3 days befored. So "new" = dated at most 3 days before the newest order already loaded, or later. Why not just after the newest? Next step shows it.
2 · First run: the full table¶
16:02:44 1 of 1 START sql incremental model gold.fct_orders ............................. [RUN]
16:02:45 1 of 1 OK created sql incremental model gold.fct_orders ........................ [OK in 0.98s]
16:02:45 Done. PASS=1 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=1
In a notebook:
| orders | newest | |
|---|---|---|
| 0 | 1000 | 2025-05-29 |
All 1,000 orders: the table didn't exist, so is_incremental() was false.
3 · New data arrives¶
Simulate the next day's Bronze load in your starter table: two new orders, and two existing orders cancelled, 998 (dated 2025-05-29) and 557 (2025-05-25). In a notebook:
%%sql
INSERT INTO my.bronze.sample_orders VALUES
(1001, 42, 'web', TIMESTAMP_NTZ '2025-05-30 09:15:00', 'placed', 'EUR', NULL),
(1002, 77, 'app', TIMESTAMP_NTZ '2025-05-30 11:40:00', 'placed', 'USD', NULL)
INSERT INTO … VALUES (…), (…)adds rows, one( )per row, values in column order.TIMESTAMP_NTZ '…'writes a timestamp value (the column's type: a timestamp without time zone).UPDATE … SET status = … WHERE …changes the matching rows in place, asMERGEdoes for matched rows.
4 · Second run: only the changes¶
16:02:58 1 of 1 START sql incremental model gold.fct_orders ............................. [RUN]
16:03:01 1 of 1 OK created sql incremental model gold.fct_orders ........................ [OK in 2.76s]
16:03:01 Done. PASS=1 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=1
What dbt ran is in target/run/shop/models/marts/fct_orders.sql: your SELECT (with the
where) goes into a temporary view fct_orders__dbt_tmp, then:
merge into gold.fct_orders as DBT_INTERNAL_DEST
using fct_orders__dbt_tmp as DBT_INTERNAL_SOURCE
on
DBT_INTERNAL_SOURCE.order_id = DBT_INTERNAL_DEST.order_id
when matched then update set
*
when not matched then insert *
The unique_key became the ON condition. The where selected orders from 2025-05-26
on (the newest loaded date, 2025-05-29, minus 3 days): 5 rows instead of 1,002. Check the four
orders that changed:
%%sql
SELECT order_id, order_date, status, loaded_at FROM my.gold.fct_orders
WHERE order_id IN (557, 998, 1001, 1002) ORDER BY order_id
| order_id | order_date | status | loaded_at | |
|---|---|---|---|---|
| 0 | 557 | 2025-05-25 | shipped | 2026-10-13 16:02:44.742014 |
| 1 | 998 | 2025-05-29 | cancelled | 2026-10-13 16:02:59.598102 |
| 2 | 1001 | 2025-05-30 | placed | 2026-10-13 16:02:59.598102 |
| 3 | 1002 | 2025-05-30 | placed | 2026-10-13 16:02:59.598102 |
- 1001, 1002: new, inserted (
count(*)is now 1,002). - 998: already there, inside the window, so it was updated to
cancelled. - 557: also cancelled in Bronze, but dated 2025-05-25, outside the 3-day window: not
selected, still
shipped, with the first run'sloaded_at.
That's the trade-off of every incremental model: it only sees what the filter lets through. The lookback window (3 days here) catches late changes to recent orders. Choose it from how late your data really changes: a filter of only "newer than the newest" would also have missed 998.
5 · Rebuild everything: --full-refresh¶
When the filter missed something, or you changed the model's columns, rebuild from scratch:
16:03:12 1 of 1 START sql incremental model gold.fct_orders ............................. [RUN]
16:03:13 1 of 1 OK created sql incremental model gold.fct_orders ........................ [OK in 0.94s]
16:03:13 Done. PASS=1 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=1
%%sql
SELECT order_id, status FROM my.gold.fct_orders WHERE order_id IN (557, 998) ORDER BY order_id
| order_id | status | |
|---|---|---|
| 0 | 557 | cancelled |
| 1 | 998 | cancelled |
--full-refresh makes is_incremental() false for this run: the table is rebuilt from all
rows, and 557 is right again. On a big table that's the slow, expensive run, so it's a repair
tool, not a habit.
6 · Put your starter table back¶
Undo the simulated day, so the counts in other lessons still match:
Then dbt run -s fct_orders --full-refresh once more.
When incremental, when table?
Start with table: simpler, always correct. Switch to incremental when rebuilding
takes too long, and only with a reliable column to filter on (a date or a loaded-at
timestamp) plus a unique_key. For data that is only ever added, never changed (event
logs), use incremental_strategy='append' with no unique_key: dbt runs a plain
insert into … select …, with no matching.
🎯 This runs unchanged on Azure, Databricks, Snowflake & Fabric¶
is_incremental(), {{ this }}, unique_key and --full-refresh are core dbt. The strategies
differ per adapter: merge on Databricks (Delta) and Snowflake, delete+insert or append
on some others. The model code stays the same.
You can now…¶
- Build an incremental model with
config(materialized='incremental', unique_key=…) - Filter new rows with
{% if is_incremental() %}and{{ this }} - Explain the
MERGEdbt runs, and what the lookback window catches and misses - Repair or rebuild an incremental table with
--full-refresh - Choose between table and incremental