skip to content

In a dbt incremental model, when does the is_incremental() macro return true?

level: middleimportance: must knowfreq 76%

answer

  1. three conditions, all must hold
  2. the very first run behaves differently
  3. a command-line flag can turn it off
  4. does the target relation exist yet?
  5. materialized incremental, table exists, no full refresh

basics

~20 s

In 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
sql
-- 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context