In a dbt incremental model, when does the is_incremental() macro return true?
answer
- three conditions, all must hold
- the very first run behaves differently
- a command-line flag can turn it off
- does the target relation exist yet?
- materialized incremental, table exists, no full refresh
basics
~20 sIn dbt, is_incremental() returns true only when the model is materialized as incremental, its target relation already exists in the warehouse, and the run is not in full-refresh mode. Otherwise dbt builds the model in full and the guarded filter is skipped.
solid answer
~40 s`is_incremental()` is a dbt macro that guards the block limiting a run to new or changed rows. It returns true only when all three conditions hold: the model is configured `materialized='incremental'`, the target table already exists, and you did not pass `--full-refresh`. On the very first run — or after `--full-refresh`, or if someone dropped the table — it returns false, the `where` block is compiled out, and dbt rebuilds the whole model exactly like a table materialization. On later runs it returns true, dbt compiles the filter in, runs the model SQL into a temporary relation, and then inserts or merges that result into the existing table. Inside the guarded block you almost always reference `{{ this }}`, the model's own current relation, to work out where the last run stopped.
code
sql · 18 lines-- models/marts/fct_events.sql
{{ config(
materialized='incremental',
unique_key='event_id'
) }}
select
event_id,
user_id,
event_type,
event_at,
loaded_at
from {{ ref('stg_events') }}
{% if is_incremental() %}
-- only compiled when the table already exists and this is not a full refresh
where loaded_at > (select max(loaded_at) from {{ this }})
{% endif %}go deeper
Recall the three conditions and that the first run always builds everything. Know that the guarded block is Jinja, so on a full build the where clause simply is not in the compiled SQL.
Explain the two-step execution: your filtered SELECT lands in a temporary relation, then dbt inserts or merges it into the existing table. Be clear that dbt keeps no state and the filter predicate is yours.
Be ready to reason about which predicate to use and what it silently loses — filtering on business event time drops late arrivals, filtering on load time does not. Talk about periodic full refreshes as a drift control.
Own the policy question: which models earn incremental complexity at all, how often full refreshes run to correct drift, and how the team catches an incremental path that only breaks after the table exists.
## Why incremental models exist A `table` materialization rebuilds the whole model on every `dbt run`. For a fact table with billions of rows, or a source that only ever gains yesterday's events, that is enormous wasted compute. The `incremental` materialization builds the table once and then, on each subsequent run, processes only a slice of rows and writes them into the existing table. That creates a problem the other materializations do not have: the model SQL must behave **differently on the first run than on later runs**. On the first run there is no table, so the model must produce all of history. On later runs it should produce only the new slice. `is_incremental()` is the switch that makes one file do both. ## The three conditions `is_incremental()` returns true when **all** of these hold: 1. The model is configured with `materialized='incremental'`. 2. The target relation already exists in the warehouse. 3. dbt is not running in full-refresh mode (`dbt run --full-refresh`, or `dbt build --full-refresh`). If any is false, the macro returns false and the templated block never reaches the warehouse — the compiled SQL simply has no `where` clause from it. That makes the behaviour predictable: - **First ever run** → false → full build, table created. - **Normal scheduled run** → true → filtered build, results merged/inserted. - **`--full-refresh` run** → false → dbt drops and recreates the table from all history. - **Someone dropped the table manually** → false → dbt silently rebuilds it in full. This is why a mysteriously long run can simply mean the table went missing. ## What the model looks like ```sql {{ config( materialized='incremental', unique_key='event_id' ) }} select * from {{ ref('stg_events') }} {% if is_incremental() %} where ingested_at > (select max(ingested_at) from {{ this }}) {% endif %} ``` `{{ this }}` is the Jinja reference to the model's own relation — the table dbt is about to write into. You can only reference it inside the guarded block, because on a full build it does not exist yet. ## What dbt actually executes On an incremental run dbt does not append your `SELECT` straight into the table. It first materializes the filtered result into a temporary or intermediate relation, then issues a second statement that combines that relation with the target — an `insert` for the append strategy, a `MERGE` when a `unique_key` is set on a warehouse whose default strategy is merge, or a delete followed by an insert for `delete+insert`. Understanding this two-step shape explains a lot of behaviour: your model SQL controls *which rows are candidates*, and the strategy plus `unique_key` control *what happens to candidates that already exist in the target*. ## Why the filter is your responsibility A crucial and commonly missed point: dbt does not track state for you. There is no bookmark, no watermark table, no memory of the last successful run. `is_incremental()` only tells you *whether* to filter — the filter predicate itself is ordinary SQL you write, usually by asking the target table what it already has (`select max(...) from {{ this }}`) or by comparing against a run-time variable. If your predicate is wrong, dbt will happily do the wrong thing on schedule, and nothing will error. Two common predicates and their trade-offs: - `where loaded_at > (select max(loaded_at) from {{ this }})` — based on when the row arrived. Robust against late-arriving business events, because a row loaded today is picked up today regardless of its event date. - `where event_at > (select max(event_at) from {{ this }})` — based on business time. Simple, but it permanently skips any row whose event time is older than the current maximum, which is exactly how late data gets silently lost. ## Operational habits Because the incremental branch is only exercised after a table exists, it is easy to ship a model whose incremental path is broken and never notice in development. Habits that catch it: run the model twice in dev (once to create, once to exercise the filter); inspect `target/compiled/` to read the SQL dbt actually generated for both paths; and schedule a periodic `--full-refresh` so drift between the incremental result and a from-scratch build gets corrected and detected. Also be aware of `full_refresh: false` in a model's config — it prevents `--full-refresh` from affecting that model, which is a deliberate guard on tables so large that a rebuild is unaffordable, and a nasty surprise if you forget it is there. ## When incremental is not worth it Incremental buys build time and warehouse spend at the price of correctness risk: duplicates, missed late rows, schema drift and a build that no longer matches what a full rebuild would produce. If a table rebuilds in a couple of minutes, keep it a `table`. Reach for incremental when the full rebuild genuinely does not fit the run window or the budget.
- Does dbt remember where the last incremental run stopped?No. dbt keeps no watermark or bookmark state for incremental models. `is_incremental()` only tells you whether to apply a filter; the predicate is SQL you write yourself, typically `select max(some_timestamp) from {{ this }}` against the target table. If that predicate is wrong, the run succeeds and quietly loads the wrong rows.
- What is {{ this }} in a dbt model, and why can it only be used inside the is_incremental() block?`{{ this }}` resolves to the model's own relation — the database, schema and table dbt is building into. On a full build that relation does not yet exist when the SQL runs, so selecting from it would fail. Guarding the reference with `is_incremental()` means it only compiles on runs where the table is already there.
- How do you exercise an incremental model's incremental path while developing it?Run it twice: the first run creates the table with `is_incremental()` false, the second exercises the filtered path. Read `target/compiled/` to see the SQL dbt actually generated for each. Testing only the first build is how broken incremental filters reach production unnoticed.
- What does setting full_refresh: false in a dbt model's config do?It makes the model ignore the `--full-refresh` flag, so `is_incremental()` stays true and the table is never dropped and rebuilt by that command. It protects tables whose full rebuild is unaffordable, but it also means a genuine rebuild requires deliberately dropping the relation.
It is the difference between rewriting a ledger from scratch and adding today's page. is_incremental() is the check for whether a ledger already exists — if there is no book yet, or you have been told to start over, you write the whole thing again.
saying these in an interview costs you the question
- Believing dbt stores a watermark of the last incremental run
- Saying is_incremental() is true on the first run too
- Forgetting that --full-refresh makes it false
- Referencing {{ this }} outside the incremental guard
- Thinking the filter block controls what happens to duplicate rows