Walk me through what dbt sends to the warehouse on the first run of an incremental model and on the second. What is different about each?
The project — work out what dbt does with it before reading on
The source rows
| as_of | order_id | customer_id | amount | updated_at |
|---|---|---|---|---|
| 1 | 1 | 10 | 50 | 2026-03-01 09:00:00 |
| 1 | 2 | 11 | 30 | 2026-03-01 10:00:00 |
The project
models/stg_orders.sql
The orders table as it currently stands. Each version replaces the last.
select
order_id,
customer_id,
amount,
updated_at
from {{ ref('order_versions') }}
where as_of = (select max(as_of) from {{ ref('order_versions') }})
models/fct_orders.sql
The default incremental strategy for this adapter. Read both runs' SQL.
{{ config(materialized='incremental', unique_key='order_id') }}
select order_id, customer_id, amount, updated_at
from {{ ref('stg_orders') }}
{% if is_incremental() %}
where updated_at > (select max(updated_at) from {{ this }})
{% endif %}
What gets run
run 1dbt seed
run 1dbt run
run 2dbt seed
run 2dbt run
Why they ask this
The foundation of every other incremental question. If the two-statement picture is not clear, nothing about strategies, unique keys or drift can be reasoned about.
Say this
On the first run the relation does not exist, so `is_incremental()` is false and dbt issues a plain CREATE TABLE from the unfiltered select. On the second it builds your filtered select into a temporary relation and merges that into the existing table.
The reasoning
First run: `is_incremental()` is false because the target does not exist, so the guarded filter is not emitted at all. The compiled SQL is a select over everything, and the run SQL is `create table ... as (...)`. Identical in shape to a `table` model.
Second run: the guard is true, so the filter compiles in — usually a comparison against `max(updated_at)` in `{{ this }}`, the table being built. dbt then does something the model file never mentions: it materializes your select into a temporary relation, and issues a second statement to combine that temporary relation with the target. Reading `target/run/` is the only way to see this.
Which combining statement you get depends on the strategy, and the default is adapter-specific — `merge` on Snowflake, BigQuery and Databricks, `append` or `delete+insert` elsewhere. That is worth stating explicitly, because an answer that says "it does a MERGE" is right on some warehouses and wrong on others.
The consequence to carry into every other question: an incremental model's output is a function of every run it has ever had, not just of its inputs. A `table` model is correct by construction. An incremental one is correct only if the filter, the key and the strategy agree — which is why the check that matters is comparing it against a full refresh.
What dbt did — 4 commands, in order run on dbt-core 1.12.2 / duckdb
run 1dbt seed
run 1dbt run
First run: no target yet, so the guard is false and the filter is absent.
Compiled SQLmodels/fct_orders.sql
select order_id, customer_id, amount, updated_at from "analytics"."main"."stg_orders"
What dbt actually ranmodels/fct_orders.sql
create table
"analytics"."main"."fct_orders"
as (
select order_id, customer_id, amount, updated_at
from "analytics"."main"."stg_orders"
);The warehouse now holds
| order_id | customer_id | amount | updated_at |
|---|---|---|---|
| 1 | 10 | 50 | 2026-03-01 09:00:00 |
| 2 | 11 | 30 | 2026-03-01 10:00:00 |
run 2dbt seed
First, 3 rows land in order_versions — order 1 is corrected to 55 and order 3 is new
run 2dbt run
Compiled SQLmodels/fct_orders.sql
select order_id, customer_id, amount, updated_at from "analytics"."main"."stg_orders" where updated_at > (select max(updated_at) from "analytics"."main"."fct_orders")
What dbt actually ranmodels/fct_orders.sql
delete from "analytics"."main"."fct_orders"
where (
order_id) in (
select (order_id)
from "fct_orders__dbt_tmp<ts>"
);
insert into "analytics"."main"."fct_orders" ("order_id", "customer_id", "amount", "updated_at")
(
select "order_id", "customer_id", "amount", "updated_at"
from "fct_orders__dbt_tmp<ts>"
)The warehouse now holds
| order_id | customer_id | amount | updated_at |
|---|---|---|---|
| 1 | 10 | 55 | 2026-03-02 10:00:00 |
| 2 | 11 | 30 | 2026-03-01 10:00:00 |
| 3 | 10 | 70 | 2026-03-02 09:00:00 |
the same thing rebuilt from scratchdbt run --select fct_orders --full-refresh
Every row that ever arrived, recomputed in one pass. It agrees with what the incremental runs built.
| order_id | customer_id | amount | updated_at |
|---|---|---|---|
| 1 | 10 | 55 | 2026-03-02 10:00:00 |
| 2 | 11 | 30 | 2026-03-01 10:00:00 |
| 3 | 10 | 70 | 2026-03-02 09:00:00 |
The answer most people give
"It inserts the new rows." Only with the append strategy. With a unique_key it merges or deletes-and-inserts, which is what lets a corrected row update in place rather than appearing twice.
They’ll ask next
What is the default incremental strategy on your warehouse, and how would you check?
