Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT

Incremental Models in Depth

Why incremental models exist, how is_incremental() actually works, the append / delete+insert / merge strategies, unique_key, full-refresh recovery, and the classic bug where incremental and full-refresh runs silently diverge.

65 min September 2026
// Part 01 — The Problem Incremental Models Solve

Why Rebuilding a Multi-Billion-Row Table Every Run Is Unacceptable

A table materialization rebuilds a model completely, every single time dbt runexecutes. dbt drops (or renames out) the existing table, runs the model's full SELECTstatement from scratch, and writes the entire result set back as a brand-new table. For a small dimension table with ten thousand rows, this is trivial — it finishes in under a second and nobody notices. For a fact table tracking every order, page view, or sensor reading a company has ever recorded, this stops being trivial extremely quickly.

Picture fct_orders at a mid-size e-commerce company with three years of history: two billion rows. The model's SELECT joins orders to customers, products, promotions, and shipping — a non-trivial amount of compute per row. Rebuilt as a full table on every hourly run, this query scans and reprocesses all two billion rows every single hour, even though only the last hour's few thousand new and updated orders actually need to be reflected. The warehouse bill for this pattern grows in direct proportion to how much history accumulates — the query gets slower and more expensive every single day, forever, for no benefit, because the vast majority of those two billion rows have not changed since the previous run and did not need to be touched at all.

The core insight incremental models are built on: most fact tables are append-mostly and time-ordered. New rows arrive constantly; existing rows rarely change once they're a few days old. If a model can identify only the rows that are new or recently changed since its last run, it can process a few thousand rows instead of a few billion — while still ending up in the same eventual state as if it had rebuilt from scratch.

An incremental model materialization does exactly this. On the first run (or a full-refresh), it behaves like a table materialization — it builds the entire table from the model's SELECT. On every subsequent run, it runs a different, narrower query: process only the rows that are new since the last run, and merge them into the existing table rather than rebuilding it. The existing two billion rows sit untouched on disk. Only the new slice is computed and written.

MaterializationWhat happens on every runCost as history grows
tableDrops and fully rebuilds the entire table from the model SELECT.Grows without bound — every run reprocesses all historical rows again.
viewNo storage at all; the SELECT runs at query time against the underlying tables.Cost is paid by whoever queries it, every time, not by dbt at build time.
incrementalRebuilds fully only on the first run or a full-refresh; every other run processes only new/changed rows and merges them in.Roughly constant — proportional to how much data changed since the last run, not to total history.
This is an optimization, not a different result
Done correctly, an incremental model and the equivalent table model should produce identical final data — incremental is purely a performance and cost strategy for how that data gets there. The moment incremental and full-refresh start producing genuinely different rows, something in the model's logic is broken. Part 06 covers exactly how that divergence happens and how to guard against it.
// Part 02 — Basic Configuration

Turning a Model Incremental: the config Block

Making a model incremental starts with one line in its config() call. Everything else — the actual incremental filtering logic — is added on top of this base configuration using Jinja, covered in Part 03.

models/marts/fct_page_views.sql — minimal incremental config
{{
  config(
    materialized='incremental'
  )
}}

select
    event_id,
    user_id,
    page_url,
    event_time,
    session_id
from {{ ref('stg_page_views') }}

As written, this model works, but it is not actually doing anything smarter than a table materialization yet — on every run, dbt still has no idea which rows are "new," so without anis_incremental() block (Part 03), the raw SQL is identical on every run, and dbt falls back to appending the entire result of that SELECT again, duplicating every row that was already there. The config only tells dbt how to materialize the model — it does not, by itself, filter anything. The filtering logic has to be written explicitly, which is the entire point of Part 03.

Config keyPurposeTypical value
materializedDeclares the model as incremental instead of table/view/ephemeral.'incremental'
unique_keyIdentifies "the same row" across runs, required for update-in-place strategies.'order_id' or a list of columns for a composite key
incremental_strategyWhich mechanism dbt uses to merge new data into the existing table.'merge', 'delete+insert', or 'append'
on_schema_changeWhat to do if new columns appear in the model SELECT that aren't in the existing table.'append_new_columns' or 'sync_all_columns'

Every one of these lives inside the same config() call. A fully specified incremental model — the shape you will write in almost every real project — combines all four:

A fully specified incremental config
{{
  config(
    materialized='incremental',
    unique_key='order_id',
    incremental_strategy='merge',
    on_schema_change='append_new_columns'
  )
}}
// Part 03 — is_incremental() — The Core Mechanic

The is_incremental() Jinja Block Is the Entire Idea

Everything about incremental models comes down to one piece of Jinja: {% if is_incremental() %}. The code inside this block only runs on an incremental run — not on the very first run (when the table doesn't exist yet) and not on a full-refresh (when dbt is deliberately rebuilding from scratch). On those two runs, dbt needs the full, unfiltered SELECT to populate the entire table. On every other run, dbt needs the narrow, filtered SELECT that only picks up new rows. One SQL file has to express both, and is_incremental() is the switch between them.

models/marts/fct_page_views.sql — with the is_incremental() filter
{{
  config(
    materialized='incremental',
    unique_key='event_id'
  )
}}

select
    event_id,
    user_id,
    page_url,
    event_time,
    session_id
from {{ ref('stg_page_views') }}

{% if is_incremental() %}

  -- this filter only applies on an incremental run:
  where event_time > (select max(event_time) from {{ this }})

{% endif %}

{{ this }} is a special Jinja variable that refers to the model's own current database relation — for this model, the actual fct_page_views table that already exists from the previous run. The subquery select max(event_time) from {{ this }}asks the existing table "what is the newest event you already contain?" and the outerwhere clause then asks the source, stg_page_views, for only rows newer than that. This is the entire mechanism: look at what you already have, then pull only what's newer.

Run typeis_incremental() evaluates toWhat actually executes
First-ever run (table does not exist)falseThe full, unfiltered SELECT — every row from stg_page_views, building the table from scratch.
dbt run --full-refreshfalseSame as above — full rebuild, regardless of what the table already contains.
A normal incremental run, table already existstrueThe filtered SELECT — only rows with event_time newer than the table's current max.

Internally, is_incremental() returns true only when all three of these hold at once: the model is configured as materialized='incremental'; the target relation already exists in the warehouse; and dbt is not running with --full-refresh. Any one of those being false collapses it back to a full build. This is why a brand-new incremental model's very first dbt run always does a full historical load — there's nothing in{{ this }} yet for the filter to compare against, so dbt correctly skips the{% if %} block entirely rather than erroring on a table that doesn't exist.

Everything outside the if-block still runs every time
Only the code physically inside {% if is_incremental() %}{% endif %} is conditional. The select, the joins, the column list — all of that runs on every run regardless. The is_incremental() block almost always wraps just a whereclause narrowing which rows from the source get scanned, not a separate query.

Inspecting exactly what dbt compiled, for either branch

Because is_incremental() changes what SQL actually runs depending on the run type, it is easy to be unsure which branch a given run actually took. dbt compile resolves all Jinja, including is_incremental(), and writes the resulting plain SQL to thetarget/compiled/ directory — a direct way to confirm, before ever running the model, exactly which version of the query a given invocation will execute.

Confirming which branch compiles, without running anything
dbt compile --select fct_page_views
# then inspect the generated file, e.g.:
cat target/compiled/my_project/models/marts/fct_page_views.sql

# On a normal run against an existing table, the WHERE clause from
# inside is_incremental() will be present in the compiled output.
# Run with --full-refresh and recompile, and that WHERE clause
# disappears entirely from the compiled SQL — direct proof of
# which branch is_incremental() actually took for that invocation.

This is the single most reliable way to debug a suspected divergence between incremental and full-refresh behavior (Part 06) — rather than reasoning about what the Jinja should do, compile both variants and diff the actual generated SQL directly.

// Part 04 — unique_key

unique_key: Telling dbt What "The Same Row" Means

event_time > max(event_time) is enough for pure append-only data — a page view event is immutable once it happens; it is never edited after the fact. But a large share of real fact tables are not pure append-only. An order can be placed, then have its status updated to "shipped" a day later, then "delivered" three days after that — the same order_idreappearing in the source with an updated updated_at timestamp and different column values each time. Here, simply appending every new-looking row produces three separate rows for one order instead of one row reflecting its current state.

unique_key tells dbt which column (or combination of columns) identifies "the same logical row" across runs, so that when a row with a matching key shows up again, dbt can update the existing row in place instead of blindly inserting a duplicate.

unique_key with a single column
{{
  config(
    materialized='incremental',
    unique_key='order_id',
    incremental_strategy='merge'
  )
}}

select
    order_id,
    customer_id,
    order_status,
    total_amount,
    updated_at
from {{ ref('stg_orders') }}

{% if is_incremental() %}
  where updated_at > (select max(updated_at) from {{ this }})
{% endif %}

unique_key can also be a list, for models where no single column is unique on its own — a common case in event or line-item level fact tables where the natural key is composite.

unique_key as a composite key
{{
  config(
    materialized='incremental',
    unique_key=['order_id', 'line_item_id'],
    incremental_strategy='merge'
  )
}}
unique_key without a matching strategy does nothing useful
Setting unique_key only matters if the incremental strategy actually uses it to find and update matching rows — merge and delete+insert both do; plainappend ignores it entirely and inserts every row from the filtered SELECT regardless of whether its key already exists in the table. Part 05 covers exactly how each strategy uses (or doesn't use) unique_key.

Picking the wrong unique_key — a subtler failure than picking none at all

A unique_key that is not actually unique in the source data is a quieter failure mode than omitting one entirely. If two rows in a single incremental batch share the sameunique_key value — for example, two updates to the same order arriving within the same run because the source system emitted both a status-change event and a totals-recalculation event for it — a merge statement's behavior when the same key appears twice in the source side of the join is warehouse-dependent: some engines error outright, others silently apply one of the two updates and discard the other, non-deterministically. Either outcome is worse than an obvious failure, because the second is silent.

Deduplicating within a batch before the merge sees it
with orders as (

    select
        order_id,
        customer_id,
        order_status,
        updated_at,
        row_number() over (
            partition by order_id
            order by updated_at desc
        ) as row_num
    from {{ ref('stg_orders') }}

    {% if is_incremental() %}
    where updated_at > (select max(updated_at) from {{ this }})
    {% endif %}

)

select
    order_id, customer_id, order_status, updated_at
from orders
where row_num = 1   -- keep only the newest version of each order_id
                    -- within THIS batch, before it ever reaches the MERGE

This pattern — a row_number() window function partitioned by the intendedunique_key, keeping only the newest row per key — is the standard defense against a batch containing more than one update for the same key, and is worth adding by default to any incremental model where the source could plausibly emit more than one event per key in a single incremental window.

// Part 05 — Incremental Strategies

append, delete+insert, and merge — Three Different Mechanics

incremental_strategy controls the actual SQL dbt generates to combine the new, filtered rows with the existing table. The three you will use in practice behave very differently, and picking the wrong one for a given model's update pattern produces either duplicate rows or silently-stale data.

append — insert only, no updates, ever

append is the simplest possible strategy: dbt runs the filtered is_incremental()SELECT and inserts every row it returns straight into the existing table. It never checks whether a row with a matching key already exists, and it never updates or deletes anything. It is the fastest and cheapest strategy by a wide margin, because it is a single, unconditionalINSERT INTO ... SELECT with no matching or scanning of the existing table required.

append — what dbt actually runs
insert into analytics.fct_page_views
select
    event_id, user_id, page_url, event_time, session_id
from analytics_staging.stg_page_views
where event_time > (select max(event_time) from analytics.fct_page_views)

The catch: append cannot handle late-arriving updates to existing rows. If the sameevent_id is somehow re-emitted by the source with a corrected value, appendinserts it as a second row rather than replacing the first — it has no concept of "the same row" because it never looks at unique_key at all. append is the right choice only for genuinely immutable, append-only event data: page views, clickstream events, IoT sensor readings — data that is written once and never edited again.

delete+insert — delete matching keys, then insert the new batch

delete+insert runs in two explicit steps: first, delete every row in the existing table whose unique_key matches a key present in the new, filtered batch; second, insert the entire new batch. Net effect: any row whose key reappears gets replaced wholesale by its newest version, and any row with a genuinely new key is added. This is a common default on Snowflake and BigQuery, where it is often implemented efficiently using a temporary table holding the new batch.

delete+insert — what dbt actually runs, in two steps
-- Step 1: delete existing rows whose key appears in the new batch
delete from analytics.fct_orders
where order_id in (
    select order_id from analytics_staging.stg_orders__dbt_tmp
)

-- Step 2: insert the entire new/updated batch
insert into analytics.fct_orders
select * from analytics_staging.stg_orders__dbt_tmp

Because this runs as two separate statements, a failure between them (a killed session, a warehouse timeout) can leave the table in a transiently inconsistent state — rows deleted but not yet reinserted — unless the warehouse wraps both steps in a single transaction, which Snowflake and BigQuery both do for this exact reason. Even so, delete+insert is inherently a two-statement operation and therefore slightly more overhead than a single native MERGE.

merge — one native MERGE statement, insert-or-update in a single operation

merge generates a single native SQL MERGE statement: for every row in the new batch, if a row with a matching unique_key already exists in the target table, update it in place; otherwise, insert it as a new row. This is the most warehouse-native mechanism available on platforms that support MERGE natively — Snowflake, BigQuery, Databricks, and Redshift all do — and it is typically the preferred and default choice on Snowflake specifically, where MERGE is a well-optimized, single-statement operation.

merge — the native MERGE statement dbt generates
merge into analytics.fct_orders as target
using analytics_staging.stg_orders__dbt_tmp as source
on target.order_id = source.order_id

when matched then update set
    customer_id   = source.customer_id,
    order_status  = source.order_status,
    total_amount  = source.total_amount,
    updated_at    = source.updated_at

when not matched then insert (
    order_id, customer_id, order_status, total_amount, updated_at
) values (
    source.order_id, source.customer_id, source.order_status,
    source.total_amount, source.updated_at
)

Because it is one atomic statement rather than two, merge avoids the transient inconsistency window of delete+insert and is generally the fastest option when the warehouse's query optimizer can push the join efficiently — which is essentially always true on modern cloud warehouses at the row volumes incremental models are built for.

StrategyHandles updates to existing keys?Statements runBest for
appendNo — always inserts, never checks existing keys.1 (INSERT)Pure immutable event streams: page views, clickstream, sensor logs.
delete+insertYes — deletes matching keys, then reinserts the whole batch.2 (DELETE, then INSERT)Warehouses without efficient native MERGE, or when a full row replace is simpler to reason about.
mergeYes — a single statement inserts new keys and updates matching keys.1 (MERGE)Snowflake, BigQuery, Databricks — the default choice for any fact table with mutable rows.
incremental_strategy defaults vary by adapter
If incremental_strategy is left unset, dbt picks a sensible default per warehouse adapter — merge on adapters that support it well, append on some others. Do not rely on the default silently being correct for a model with mutable rows — setincremental_strategy explicitly whenever unique_key is also set, so the two configs are never accidentally mismatched.

A decision framework — walking through the actual choice

In practice, picking a strategy comes down to answering two questions about the model's source data, in order. First: can a row that has already landed ever be changed or corrected later? If the honest answer is no — the data is genuinely append-only, like an immutable event log — thenappend is correct, and adding unique_key or a fancier strategy only adds overhead for no benefit. If the answer is yes, move to the second question: does the target warehouse support an efficient native MERGE statement? Snowflake, BigQuery, Databricks, and Redshift all do, which makes merge the right default. On an adapter whereMERGE is poorly optimized or unsupported, delete+insert is the fallback that still correctly handles updates to existing rows.

The decision, as a short flowchart in comment form
Can an existing row's data ever be corrected or updated after it first lands?
│
├── No (genuinely immutable events) ──────────────► incremental_strategy='append'
│
└── Yes (rows can be corrected/updated) ──► Does the warehouse support native MERGE well?
                                              │
                                              ├── Yes (Snowflake, BigQuery, Databricks) ──► incremental_strategy='merge'
                                              │
                                              └── No / unsure ─────────────────────────────► incremental_strategy='delete+insert'

A common real mistake is answering the first question wrong by assumption rather than by checking the actual source system. "Orders never change once placed" sounds true until a refund, a status correction, or a support-tooling backfill script proves otherwise months later — exactly the pattern in this module's Real World section. When in doubt, default to merge with aunique_key set; the cost of an unnecessary key check on genuinely immutable data is small, while the cost of silently accumulating duplicate rows under append on data that turned out to be mutable is a real, compounding data quality bug.

// Part 06 — Full Refresh and the Divergence Bug

dbt run --full-refresh, and the Classic Bug Class It Exists to Fix

dbt run --full-refresh (or dbt build --full-refresh) forces dbt to drop and completely rebuild an incremental model from scratch — running the full, unfiltered SELECT as if the model had never existed before, exactly as it would on its very first run. Every reason to reach for it comes down to the same underlying need: the incrementally-built table and the full-rebuild-from-scratch table have stopped agreeing, and a full-refresh is how you force them back into agreement.

When to run --full-refreshWhy
The incremental logic itself changedA new column was added, a join was fixed, a filter condition changed — the existing table reflects the OLD logic and needs to be rebuilt under the new logic to be consistent.
unique_key changedExisting rows were merged under the old key definition; changing the key without a full-refresh leaves the table in a mixed, inconsistent state.
A backfill is neededHistorical source data was corrected or newly loaded further back than the model's incremental filter would ever look — the filtered is_incremental() SELECT will never reach it on its own.
The incremental filter is suspected to have drifted from full-refresh behaviorSee the divergence bug below — this is the recovery path once you've confirmed a mismatch.

The divergence bug: incremental runs and full-refresh runs producing different results

This is the single most consequential bug class in incremental model design, and it is subtle precisely because both code paths "work" individually — the bug is that they silently disagree with each other. It happens when the WHERE clause inside the is_incremental()block does not exactly match the implicit scope of the full, unfiltered SELECT that runs outside it.

The bug — an incremental filter narrower than the full SELECT's actual scope
select
    order_id,
    customer_id,
    order_status,
    total_amount,
    updated_at
from {{ ref('stg_orders') }}
where order_status != 'cancelled'   -- ← this filter applies on EVERY run

{% if is_incremental() %}
  and updated_at > (select max(updated_at) from {{ this }})
{% endif %}

-- Looks fine. But now: what happens to an order that WAS 'cancelled'
-- at the time it first landed (correctly excluded), and later gets its
-- status corrected back to a valid, non-cancelled state?
--
-- On the FULL-REFRESH path: the current stg_orders row for that order
-- is scanned fresh, its order_status is now valid, and it IS included.
--
-- On the INCREMENTAL path: the filter only looks at updated_at > max(updated_at).
-- If that order's updated_at was NOT bumped when its status changed
-- (a common upstream mistake), the incremental run never sees it at all —
-- it silently stays missing from the table.
--
-- Result: dbt run keeps the order missing forever.
--         dbt run --full-refresh brings it back.
-- Two runs of the "same" model produce two different tables.

The fix is not a clever trick — it is discipline: the condition that decides which rows are eligible to appear in the model at all (here, order_status != 'cancelled') must be evaluated consistently regardless of which path runs, and the incremental filter's job is only to narrow which rows to re-scan for changes, never to silently redefine which rows are eligible to exist in the model. In practice this usually means either filtering on a column that reliably updates whenever anything relevant about the row changes, or widening the incremental window to re-scan slightly more than the strict minimum, trading a little extra compute for correctness.

When in doubt, widen the incremental window, don't narrow it
A slightly too-wide is_incremental() filter costs a little extra compute — it re-processes a few rows that didn't actually need it. A too-narrow filter silently drops or permanently stales real rows with no error, no warning, and no signal that anything is wrong until someone notices the numbers don't match a full-refresh. Given that asymmetry, err toward re-scanning slightly more than the theoretical minimum.

A useful habit for catching this class of bug before it reaches production: periodically rundbt run --full-refresh against a model in a staging environment and diff its row count and key aggregates against the incrementally-built production table. If they ever disagree, the incremental filter has drifted from the full-refresh scope, and that is the signal to go looking for exactly the kind of hidden extra WHERE condition shown above.

// Part 07 — Worked Example

A Real Incremental Fact Table: fct_orders End to End

Bringing every piece together: a real fct_orders model using unique_key, the merge strategy, and an incremental filter on updated_at — the shape this exact model takes in the large majority of production dbt projects.

models/marts/fct_orders.sql — full worked example
{{
  config(
    materialized='incremental',
    unique_key='order_id',
    incremental_strategy='merge',
    on_schema_change='append_new_columns'
  )
}}

with orders as (

    select
        order_id,
        customer_id,
        order_placed_at,
        order_status,
        updated_at
    from {{ ref('stg_orders') }}

    {% if is_incremental() %}
    where updated_at > (select max(updated_at) from {{ this }})
    {% endif %}

),

order_items as (

    select
        order_id,
        sum(line_item_amount) as items_subtotal,
        count(*) as line_item_count
    from {{ ref('stg_order_items') }}
    group by 1

),

final as (

    select
        orders.order_id,
        orders.customer_id,
        orders.order_placed_at,
        orders.order_status,
        orders.updated_at,
        order_items.items_subtotal,
        order_items.line_item_count
    from orders
    left join order_items
        on orders.order_id = order_items.order_id

)

select * from final

Trace what happens on each kind of run. On the very first dbt run, the target tablefct_orders does not exist, so is_incremental() is false, thewhere clause is skipped entirely, and every historical order is processed and inserted — a full build, identical to what a table materialization would do. On every subsequentdbt run, the table exists, is_incremental() is true, and the query only pulls orders whose updated_at is newer than the newest one already infct_orders — typically a few thousand rows instead of the full historical volume. Themerge strategy then updates any order whose status changed (shipped, delivered, refunded) in place, and inserts any genuinely new order as a new row.

Notice that updated_at is chosen deliberately, not order_placed_at. A filter on order_placed_at would only ever catch brand-new orders — it would never re-scan an existing order whose status later changed, because that order's placed_atvalue never changes even though the row itself does. This is exactly the divergence trap from Part 06: the incremental filter column must track "this row changed," not "this row was created."

Backfilling three months of corrected history without touching the rest
# A backfill is a full-refresh scoped, in practice, by first fixing upstream
# data and then forcing a rebuild — dbt itself has no partial-range refresh
# built into the base incremental materialization, so the standard pattern is:

dbt run --select fct_orders --full-refresh

# For very large tables where a full rebuild is too expensive just to fix
# 3 months of data, some teams instead add a manual backfill script that
# runs the model's SQL with an explicit date range substituted in place of
# is_incremental()'s filter, and MERGEs just that range back in.

The compute difference between the two paths is the entire reason incremental models exist in the first place, and it is worth making concrete with real numbers for this exact model.

Run typeRows scannedApproximate cost driver
First-ever run / --full-refresh on 2 years of history~2,000,000,000 rowsFull join of orders, order_items across the entire historical volume — the expensive path, run rarely.
A normal hourly incremental run~4,000–8,000 rowsOnly orders with updated_at newer than the current max — a few thousand rows regardless of how much total history exists.
A 3-month targeted backfill (manual date-range script)~180,000,000 rowsBounded to the affected date range only, far cheaper than a full 2-year rebuild but still far more than a routine hourly run.

This is the practical payoff in one table: routine runs stay cheap and roughly constant no matter how much history accumulates, while the expensive full-scan path is reserved for the rare occasions — first build, a logic change, a backfill — where it is genuinely unavoidable.

// Part 08 — Operational Concerns

Schema Changes, Late-Arriving Data, and Monitoring an Incremental Model

on_schema_change — what happens when the model's columns change

Unlike a table materialization, which simply drops and recreates the table with whatever columns the current SELECT produces, an incremental model's existing table already has a fixed schema from a previous run. If the model's SQL is edited to add a new column, dbt needs to decide what to do about the mismatch between the existing table's schema and the new SELECT's schema.

on_schema_change valueBehavior
'ignore' (default)New columns in the SELECT are silently dropped when merging into the existing table — the mismatch is not resolved automatically.
'append_new_columns'New columns found in the SELECT are added to the existing table (as nullable, backfilled with NULL for old rows); removed columns are left alone.
'sync_all_columns'The existing table's columns are fully reconciled to match the current SELECT — new columns added, removed columns dropped.
'fail'The run errors out immediately if a schema mismatch is detected, forcing a deliberate, explicit full-refresh instead of an automatic reconciliation.

append_new_columns is the most common real-world choice — it lets a model evolve incrementally without a mandatory full-refresh on every schema change, while sync_all_columnsis used when keeping the table strictly matched to the model definition matters more than preserving old, now-unused columns.

A schema change in practice — adding a discount_amount column
-- Before: fct_orders has order_id, customer_id, total_amount, updated_at
-- Model SQL is edited to add a new column:

select
    order_id,
    customer_id,
    total_amount,
    discount_amount,   -- newly added
    updated_at
from {{ ref('stg_orders') }}

{% if is_incremental() %}
  where updated_at > (select max(updated_at) from {{ this }})
{% endif %}

-- With on_schema_change='append_new_columns':
-- dbt ALTERs the existing fct_orders table to add discount_amount
-- (as a nullable column), backfilling NULL for every row that already
-- existed, then proceeds with the normal incremental merge for new rows.
-- Old rows show NULL for discount_amount until they happen to be
-- re-processed by a future incremental run — they are NOT retroactively
-- backfilled with a real value automatically.
append_new_columns does not backfill historical values
Adding a column with append_new_columns only changes the schema going forward — every row that existed before the change shows NULL for the new column, not some computed historical value. If the new column genuinely needs a correct value for old rows too, that requires a deliberate --full-refresh, not just the schema-change mechanism.

Late-arriving data — the limit of any incremental filter

Every incremental filter makes an assumption: that the source system won't hand you a row whoseevent_time or updated_at is older than the window the filter already scanned past. Late-arriving data breaks that assumption — a mobile client that buffers events offline and syncs them three days later will produce a row with an event_time from three days ago, arriving in the source table today. A naive where event_time > max(event_time)filter never catches it, because by the time it lands, the incremental window has already moved past that timestamp.

A lookback window guards against modest late-arrival
{% if is_incremental() %}
  where event_time > (
    select dateadd('hour', -3, max(event_time)) from {{ this }}
  )
{% endif %}

-- Re-scans a 3-hour overlap on every run, trading a small amount of
-- extra compute for tolerance to events that arrive a few hours late.
-- Combined with unique_key + merge, re-scanning the overlap safely
-- updates any row that already exists rather than duplicating it.

Monitoring — the signal that an incremental model is unhealthy

The single most useful health check for an incremental model in production is comparing its incrementally-built row count and key aggregates (sums, counts) against a periodic full-refresh run in a non-production environment, exactly as described in Part 06. A second useful signal is simply tracking how many rows each incremental run actually processes over time — a sudden jump from a few thousand rows per run to several million is usually a sign that the source'supdated_at column stopped updating reliably somewhere upstream, silently forcing the filter to re-scan far more history than intended.

Ephemeral and incremental do not mix
An incremental model cannot be built ref()'d from an ephemeral model in the same way a table can — because {{ this }} in the is_incremental() block needs a real, persisted relation to query max() against. Keep upstream sources for an incremental model as views or tables, not ephemeral CTEs.

An audit query pattern for ongoing confidence, without a full-refresh every time

Running a full --full-refresh diff is the most thorough check, but it is also the most expensive — it means paying the full rebuild cost just to validate correctness. A cheaper, lightweight check that catches a large share of real divergence bugs without a full rebuild is an audit query comparing row counts and a business-critical aggregate between the incremental table and its immediate upstream source, scoped to a recent window.

A lightweight audit query — no full-refresh required
-- Run this periodically (e.g. as a dbt test, or a scheduled check) against
-- the last 7 days of data, comparing the incremental model to its source:

with source_side as (
    select count(*) as source_row_count
    from {{ ref('stg_orders') }}
    where updated_at > current_date - interval '7 days'
),

fact_side as (
    select count(*) as fact_row_count
    from {{ ref('fct_orders') }}
    where updated_at > current_date - interval '7 days'
)

select
    source_side.source_row_count,
    fact_side.fact_row_count,
    source_side.source_row_count - fact_side.fact_row_count as row_count_diff
from source_side, fact_side
where source_side.source_row_count != fact_side.fact_row_count

A nonzero row_count_diff for a recent window is an early warning sign worth investigating well before it grows large enough for a stakeholder to notice in a dashboard. This kind of audit query can itself be wired up as a dbt singular test — exactly the pattern covered in the next module in this track — turning an ad hoc sanity check into an automatically enforced assertion that runs on every dbt build.

// Misconceptions

Five Misconceptions About Incremental Models

✕ ""Incremental models are just a faster way to build tables — pick incremental for every large model automatically""
Incremental adds real complexity: a unique_key to maintain, a strategy to choose, and an is_incremental() filter that can silently drift from full-refresh behavior. It is worth that complexity for genuinely large, append-mostly fact tables — not for every table above some row count. A 5-million-row dimension table that rebuilds in 20 seconds gains little from the added complexity.
✕ ""unique_key alone deduplicates rows, regardless of which incremental_strategy is set""
unique_key is only acted on by strategies that use it — merge and delete+insert both check it before deciding to update-in-place versus insert. The append strategy ignores unique_key entirely and will happily insert duplicate rows with the same key if the filtered SELECT returns them more than once.
✕ ""dbt run --full-refresh is a destructive last resort, and needing it means something is broken""
Full-refresh is a normal, expected recovery path — needed after a deliberate unique_key change, an intentional backfill, or a fixed incremental filter. It only signals something is broken when it is needed to fix a silent divergence between incremental and full-refresh output, not simply because it was run.
✕ ""The is_incremental() filter only affects performance, never correctness""
A too-narrow is_incremental() filter (Part 06) silently drops or permanently stales real rows with no error message. This is a correctness bug, not a performance one — the model produces different final data on an incremental run than on a full-refresh, and nothing in dbt's output tells you that happened.
✕ ""merge is always strictly better than delete+insert — there's no reason to ever choose delete+insert""
merge is usually preferred where the warehouse supports it well, but delete+insert remains useful when the update logic genuinely needs a full row replace rather than a column-by-column update, or on adapters where a native MERGE is not well optimized or not supported at all.
// Real World
💼 What This Looks Like at Work

Three Incremental Model Incidents, Three Different Warehouses

Instacart — grocery delivery, order fact table

An analytics engineer at Instacart owns fct_deliveries, an incremental model onupdated_at with a merge strategy. A dashboard used by regional operations managers starts showing delivery counts that are consistently a few hundred lower than the number of deliveries support tickets reference for the same day. The engineer runsdbt run --full-refresh against a clone of the model in a scratch schema and diffs row counts against production: the full-refresh version has more rows for the affected day.

The cause: a subset of delivery records get corrected hours after creation by a support-tooling backfill job that updates delivery_status directly in the source table via a bulk SQL script — a script that, unlike the normal application write path, does not touchupdated_at. Those corrected rows never re-enter the incremental filter's window. The fix is not in the dbt model at all — it is a one-line addition to the support-tooling script to set updated_at = now() on every row it touches, restoring the assumption the incremental filter depends on.

Toast — restaurant point-of-sale, transaction events

An engineering team at Toast builds fct_pos_transactions using appendas the incremental strategy, reasoning that a completed transaction never changes once recorded. This holds for months, until a card-network chargeback reversal process starts writing a corrected transaction row with the same transaction_id but an updatedamount, to reflect a partial refund applied after settlement.

With append, the corrected row is simply inserted alongside the original — the table now has two rows sharing one transaction_id, and every downstream revenue aggregation silently double-counts the original amount. The team switches the strategy tomerge with unique_key='transaction_id', and the fix requires a--full-refresh to first collapse the existing duplicate pairs before the merge logic can maintain correctness going forward — merge only prevents new duplicates, it does not retroactively fix ones an earlier append strategy already created.

Samsara — IoT vehicle telemetry, sensor readings

Samsara ingests GPS and engine telemetry from vehicle hardware that occasionally loses network connectivity and buffers readings on-device, uploading them in a burst once reconnected — sometimes several hours after the reading's actual timestamp. The original fct_vehicle_telemetrymodel filters strictly on reading_time > max(reading_time), with no lookback window, on the (reasonable-sounding) assumption that telemetry is pure append-only.

Vehicles that go through connectivity gaps — parking garages, rural routes — have their buffered readings silently excluded from every future incremental run, because by the time they arrive, the filter's window has already moved past their timestamps. The fix is the lookback-window pattern from Part 08: the filter is widened toreading_time > dateadd('hour', -6, max(reading_time)), re-scanning a 6-hour overlap on every run and relying on unique_key plus merge to safely absorb the reprocessed readings without duplicating the ones already correctly captured the first time.

// Interview Prep

5 Interview Questions — With Complete Answers

Walk me through exactly what happens, mechanically, on the first dbt run of a brand-new incremental model versus every run after that.
On the first run, the target relation doesn't exist yet, so is_incremental() evaluates to false — the code inside that Jinja block is skipped entirely, and the model's full, unfiltered SELECT runs and gets written as a new table, identical to what a table materialization would produce (Part 03). On every subsequent run, the relation exists and dbt is not in --full-refresh mode, so is_incremental() is true, the WHERE clause inside the block is included, and only the narrower set of rows it matches gets computed and merged into the existing table using whichever incremental_strategy is configured (Part 05). The first run always does the expensive full pass exactly once; every run after that is meant to be cheap.
What is unique_key for, and what breaks if you set it without also setting an appropriate incremental_strategy?
unique_key tells dbt which column (or columns) identify "the same logical row" across runs, which matters whenever a row can be updated after it first appears — an order changing status, a corrected sensor reading. It only has an effect if the incremental_strategy actually consults it: merge and delete+insert both use it to update matching rows in place; append ignores it completely and will insert a second row with the same key if the filtered SELECT ever returns a row whose key already exists in the table (Part 04 and Part 05). Setting unique_key with strategy=append silently accomplishes nothing — the config exists, but nothing in append's logic ever reads it.
Explain the difference between delete+insert and merge, and when you would deliberately choose delete+insert over merge.
delete+insert runs as two explicit statements: delete every existing row whose unique_key matches a key in the new batch, then insert the entire new batch. merge does the same net effect in one native SQL MERGE statement — update matching rows in place, insert new ones — in a single atomic operation (Part 05). merge is generally preferable where the warehouse supports it well, since it avoids the transient inconsistency window between the delete and insert steps and is typically faster. delete+insert is still the right choice on adapters where native MERGE isn't well supported or optimized, or when the update logic genuinely needs a full row replace rather than dbt's generated column-by-column MERGE update clause.
Describe the specific bug where an incremental model and its full-refresh produce different results, and how you'd design against it.
This happens when a condition that determines which rows are eligible to appear in the model at all gets applied inconsistently between the two code paths — usually because a WHERE clause outside the is_incremental() block interacts badly with a filter column, like updated_at, that doesn't reliably update whenever a row's other relevant fields change (Part 06). A row that becomes newly eligible after some upstream correction gets picked up correctly on a full rebuild (which re-scans everything fresh) but never gets picked up incrementally (because the filter never revisits it). The design defense is to make sure the incremental filter's column genuinely tracks "this row changed" for every kind of change that matters, to prefer widening the incremental window over narrowing it when in doubt, and to periodically diff a full-refresh run against production to catch drift before it's discovered by a stakeholder noticing wrong numbers.
When would you reach for dbt run --full-refresh in a real project, and is it ever a sign something is wrong?
Full-refresh is the normal recovery path any time the existing incrementally-built table needs to be reconciled against a change in the model's own logic: a changed unique_key, a materially changed SELECT, or an intentional backfill of corrected historical data that the incremental filter's window would never reach on its own (Part 06). None of those signal a problem by themselves — they're expected parts of evolving a model over time. It only signals something is actually wrong when a full-refresh is needed specifically to fix a silent divergence between what the incremental path has been producing and what a from-scratch rebuild produces for the same underlying data, since that means the is_incremental() logic has been quietly wrong for however long it's been running.
// Common Mistakes

Five Mistakes Engineers Make Building Their First Incremental Models

Filtering on a "created" timestamp instead of an "updated" one
Filtering is_incremental() on order_placed_at instead of updated_at means the model never re-scans a row after it first appears — any status change, correction, or late enrichment to an existing order is permanently invisible to future incremental runs, even though the row itself changed.
Setting unique_key without checking what incremental_strategy actually does with it
unique_key is inert under the append strategy — it does nothing to prevent duplicate rows with the same key. Engineers often set it out of habit, assume it deduplicates, and are surprised months later when duplicates show up because append never consulted it.
Never validating an incremental model against a full-refresh
Without periodically diffing an incrementally-built table against a from-scratch full-refresh, divergence bugs (Part 06) can run silently for months — nothing errors, nothing looks obviously wrong, the numbers are just quietly incomplete or stale.
No lookback window on a source with any late-arriving data
A strict WHERE column > max(column) filter with zero overlap silently drops any row that arrives after its own timestamp has already scrolled out of the incremental window — common with offline-capable clients, buffered IoT devices, and any multi-step ETL pipeline with variable latency between steps.
Treating --full-refresh as something to avoid running, ever
Some teams become so averse to the cost of a full-refresh that they avoid running one even to validate correctness, which removes the single most reliable tool for catching a divergence bug before a stakeholder does. A full-refresh in a scratch schema costs compute; an unnoticed months-long data quality bug costs trust.
// Error Library

Incremental Model Errors — And Exactly Why They Happen

Database Error: column "order_status" does not exist in the target relation
Cause: The model SQL was edited to add a new column, but on_schema_change is left at its default of 'ignore' (or was never set), so dbt does not reconcile the mismatch between the existing table's schema and the current SELECT's columns.
Fix: Set on_schema_change='append_new_columns' (to add the new column non-destructively) or run --full-refresh to rebuild the table under the new schema from scratch.
Duplicate rows appearing for the same unique_key after switching from append to merge
Cause: merge only prevents NEW duplicates going forward — it does not retroactively deduplicate rows that an earlier append-strategy run already inserted as duplicates before the switch.
Fix: Run --full-refresh after changing incremental_strategy to merge, so the table is rebuilt cleanly under the new merge logic rather than inheriting old duplicates.
Row counts silently lower on the incrementally-built table than an equivalent full-refresh, with no error raised
Cause: The classic divergence bug (Part 06) — a WHERE condition outside the is_incremental() block interacts with a filter column that does not reliably update whenever a relevant field on the row changes, so some eligible rows are never re-scanned incrementally.
Fix: Audit every filter condition that applies outside the is_incremental() block for consistency with the incremental filter column, and widen the incremental window (a lookback buffer) if the filter column's update behavior can't be fixed upstream.
Compilation Error: is_incremental() macro used outside of an incremental model context
Cause: is_incremental() was called in a model that is not configured with materialized='incremental' — the macro depends on incremental-specific state (whether the target relation exists, whether --full-refresh was passed) that only exists for incremental models.
Fix: Confirm the model's config() block actually sets materialized='incremental'; a table, view, or ephemeral model has no legitimate use for is_incremental() at all.
A --full-refresh run takes hours and times out the warehouse session, for a model that used to build in minutes
Cause: The model has simply accumulated enough historical volume that a full, unfiltered rebuild is now genuinely expensive — the exact problem incremental models exist to avoid on every run, but which a full-refresh always pays in full by design.
Fix: Increase the warehouse session/statement timeout for full-refresh runs specifically, or scope a full-refresh to run against a partitioned subset of history when the underlying table supports it, rather than the entire history at once.

🎯 Key Takeaways

  • Incremental models exist to avoid reprocessing an entire multi-billion-row table on every run — they behave like a table materialization on the first run, and process only new/changed rows on every run after that.
  • The is_incremental() Jinja block is the entire mechanic: code inside it runs only on incremental runs, typically wrapping a WHERE filter comparing the source against {{ this }}, the model's own existing relation.
  • unique_key tells dbt what "the same row" means across runs, but it only matters to strategies that check it — merge and delete+insert do, append does not.
  • append is fastest but insert-only; delete+insert deletes-then-inserts matching keys in two statements; merge does both in one native, atomic statement and is typically the default choice on Snowflake.
  • dbt run --full-refresh rebuilds an incremental model completely — needed after a unique_key or logic change, a deliberate backfill, or to recover from a silent incremental/full-refresh divergence.
  • The classic bug class is a WHERE condition or filter column that behaves inconsistently between the full SELECT and the is_incremental() filter, causing the two code paths to silently produce different final tables — guard against it by making the filter column track every relevant kind of row change, and periodically diffing against a full-refresh.
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...