dbt Incremental Models

Strategies, Merge & unique_key (dbt Core)

A dbt (data build tool) incremental model builds a table in full once, then updates it with only the new or changed rows on each run, using an incremental strategy such as append, merge, delete+insert, insert_overwrite or microbatch. With merge, dbt matches rows on unique_key, updating the ones it has and inserting the rest.

Last updated: Proudly published by: Jeff Wahl9 min read

What is a dbt incremental model?

A dbt (data build tool) incremental model is a table that dbt builds in full on its first run and, on every run after that, updates with only the rows your filter selects instead of rebuilding everything. You turn it on with materialized='incremental', and the incremental strategy you pick decides how the selected rows reach the table. They can be appended or merged on a key, or they can be delete and inserted or written over whole partitions.

This page covers dbt Core, the open source command line tool. The behavior described here comes from the dbt documentation on incremental models, and where something depends on a version or an adapter, the text says so.

Prepare for the interview
01 / Open invite
02min.

Know dbt incremental models the way the interviewer who asks it knows it.

a dbt incremental models query, the same shape a screen would give you.
The diff against expected. Where ties broke. What you missed.
sandbox
1SELECT user_id,
2 COUNT(*) AS sessions
3FROM events
4WHERE ts >= NOW() - INTERVAL '7 day'
5
Execute your solution0.4s avg.

How is_incremental() decides what to run

The model's SQL runs in 2 modes. The is_incremental() macro returns true only when all 3 conditions hold: the model already exists as a table in the database, the full refresh flag wasn't passed, and the model is configured with materialized='incremental'. Otherwise it returns false and the whole query runs, which is why the SQL must be valid in both modes.

Inside the true branch, {{ this }} refers to the target table, so the usual filter compares the source with the newest value already loaded. A typical filter keeps rows at or after the latest updated_at. It uses >= so that rows stamped at the same moment as the last load are picked up again, and coalesce to a distant date so an empty table loads everything.

A minimal incremental model with a unique_key

{{
    config(
        materialized='incremental',
        unique_key='order_id'
    )
}}

select order_id, status, updated_at
from {{ ref('stg_orders') }}

{% if is_incremental() %}
  where updated_at >= (
    select coalesce(max(updated_at), '1900-01-01') from {{ this }}
  )
{% endif %}

The filter applies only on incremental runs; the first run reads the whole source

Append vs merge: what the strategy does to rows you already loaded

The incremental strategy is the part that changes what a run does to rows already in the table. Take a target holding order ids 1, 2, 3, and a run whose filter selects ids 3, 4: id 3 changed upstream and id 4 is new.

With append, dbt inserts both selected rows without checking whether they exist, so the table ends with 5 rows and id 3 appears twice. With merge on unique_key='order_id', dbt inserts the key it hasn't seen and updates the one it has, so the table ends with 4 rows and 1 row per id. The change in the row count is the whole difference: an append-only model is safe only when selected rows are never updates of rows already loaded.

The same run, which selects ids 3 and 4 for a target already holding ids 1, 2 and 3, under 2 strategies. Append leaves 5 rows with id 3 twice; merge on unique_key leaves 4 rows with id 3 replacedThe same run, which selects ids 3 and 4 for a target already holding ids 1, 2 and 3, under 2 strategies. Append leaves 5 rows with id 3 twice; merge on unique_key leaves 4 rows with id 3 replaced

You set the strategy per model with incremental_strategy, or for a folder of models with +incremental_strategy in dbt_project.yml.

Incremental strategies side by side

append
  • Uses unique_keyNo
  • Updates existing rowsNo
  • Replaces partitionsNo
  • Documented costLow processing cost
merge
  • Uses unique_keyYes
  • Updates existing rowsYes
  • Replaces partitionsNo
  • Documented costScans whole target
delete+insert
  • Uses unique_keyYes
  • Updates existing rowsYes
  • Replaces partitionsNo
  • Documented costSlower on large data
insert_overwrite
  • Uses unique_keyNo
  • Updates existing rowsPer partition
  • Replaces partitionsYes
  • Documented costRewrites partitions

dbt merge incremental: how merge and unique_key work together

The dbt documentation on incremental strategies describes merge as inserting records whose unique_key isn't in the destination and updating records whose key is. By default a matched row is overwritten in full. merge_update_columns restricts the update to the columns you list, and merge_exclude_columns lists the ones to leave alone.

Merge scans the whole destination table to decide what to update or insert, which the documentation calls expensive for large tables. incremental_predicates is a list of SQL expressions that narrows that scan. Inside those expressions, DBT_INTERNAL_DEST is the alias for the existing rows and DBT_INTERNAL_SOURCE the alias for the incoming ones. dbt does not check their syntax. Merge without a unique_key also behaves like append, so a forgotten key produces duplicates with no error.

delete+insert reaches the same end in 2 steps: it deletes the rows for the keys it is about to load, then inserts them. The documentation notes it may be less efficient for larger datasets. For slowly changing dimensions, it points to dbt snapshots, not this strategy.

Default incremental strategy by adapter

dbt-snowflake
merge is the default, and append, delete+insert, insert_overwrite and microbatch are also supported
dbt-bigquery
merge is the default and requires a unique_key, while insert_overwrite and microbatch are also supported
dbt-postgres
append without a unique_key and delete+insert with one
Read from each adapter's configuration page in the dbt documentation

Why a unique_key still produces duplicates

A unique_key is only as good as the data behind it. The documentation warns that key columns shouldn't contain nulls, or the model may fail to match rows and generate duplicates. Wrap a nullable column in coalesce, or build a single surrogate key.

Pass several columns as a list, for example unique_key=['user_id', 'session_number'], rather than a concat expression. And the key has to be unique in the new rows too. On Snowflake, the merge statement fails with a nondeterministic merge error when the configured key isn't actually unique, and the adapter page suggests delete+insert as the 2 step alternative. Deduplicate inside the model with a row_number() window before the final select, and the error doesn't come back.

How to handle late-arriving rows in an incremental filter

A filter on max(updated_at) assumes rows arrive in order. A row stamped yesterday that reaches the warehouse today sits below the high-water mark and is skipped on every later run. The model looks healthy because nothing fails, while the table slowly drifts from its source.

The common remedy is a window that reaches back past the mark, such as updated_at >= max(updated_at) - interval '3 days', paired with a unique_key so the reprocessed rows replace themselves instead of duplicating. The width of the window is a trade: a wider window catches later arrivals and rescans more data on each run. Choose it from how late your slowest source can be, and check it against the cost of the scan.

The microbatch incremental strategy

Microbatch splits a time-series model into batches, one query per batch. The dbt documentation on microbatch lists it for dbt v1.9 or higher. You set 3 required configs. event_time is the column that records when each row occurred, and begin is where the first build starts. batch_size is one of hour, day, month or year. lookback defaults to 1 and reprocesses earlier batches to catch late records. concurrent_batches lets batches run in parallel.

The documentation's own example sets lookback=3 on a daily model, so a run on October 1 queries September 28 through October 1: 4 batches. dbt filters each upstream ref that defines an event_time, and an upstream model without one is read in full on every batch. Each batch is written with the adapter's batch mechanism: merge on Postgres, delete+insert on Snowflake and Redshift, and insert_overwrite on BigQuery and Spark. Postgres also needs a unique_key. BigQuery needs partition_by, as does Spark.

A backfill uses the event time start and event time end flags of dbt run together, since the documentation doesn't allow one without the other, and dbt retry reruns only the batches that failed. Values are read as UTC, and custom time zones aren't supported yet.

A daily microbatch model with a 3 batch lookback

{{
    config(
        materialized='incremental',
        incremental_strategy='microbatch',
        event_time='session_start',
        begin='2020-01-01',
        batch_size='day',
        lookback=3
    )
}}

select * from {{ ref('page_views') }}

What on_schema_change does when columns change

on_schema_change takes 4 values: ignore (the default), fail, append_new_columns and sync_all_columns. With the default, a column you add to the model doesn't appear in the existing table, and a column you remove makes dbt run fail. append_new_columns adds new columns without dropping missing ones. sync_all_columns also removes columns and covers data type changes.

The open source tool has limits here. No option backfills old rows for a newly added column, so the old rows hold nulls until you update them or run a full refresh. Only top-level columns are tracked, so on BigQuery a change inside a nested column does not count as a schema change.

How to full refresh an incremental model

Running dbt run with the full refresh flag and a selector for the model drops the existing table and rebuilds it as on a first run. A trailing + in the selector also runs downstream models, and downstream incremental models are fully refreshed too, which can be a large bill. Setting full_refresh to true or false in the model's config overrides the flag, and setting it to false protects a model you never want rebuilt by accident.

Rebuild after you change the model's logic. The filter only reads new rows, so old rows keep the output of the old logic until a refresh replaces them. For microbatch models the documentation advises full_refresh: false and a targeted backfill with both event time flags.

When an incremental model is the wrong choice

An incremental model trades correctness you get for free in a table or view for run time, and the trade is only worth making when the rebuild is slow or costly. A model that finishes in seconds gains nothing from a filter that can drift. Models whose rows are rewritten by logic upstream, such as a window function across the full history, are poor candidates because a partial run can't recompute them.

Microbatch isn't always the answer either: the documentation says to consider other strategies when you lack a reliable event_time column or want full control over the incremental logic. A custom strategy is possible from dbt v1.2 by defining a get_incremental_STRATEGY_sql macro, though the documentation notes custom strategies aren't supported on the BigQuery and Spark adapters.

Incremental models in data engineering interviews

Interviewers ask incremental models as a design question: how would you load a growing table without rebuilding it, and what breaks. The strong answer names the key and the filter column, then covers late data and the rebuild path. The SQL under it is a keyed upsert, so the SQL interview questions and SQL practice problems cover the reasoning, and the SQL round guide shows how it is asked. For the tool itself, see the dbt interview questions.

dbt incremental models FAQ

What is an incremental model in dbt?+
An incremental model is a table that dbt builds in full on the first run and then updates with only the rows your filter selects on later runs. You set it with materialized='incremental' and write the filter inside an is_incremental() block.
What is the default incremental strategy in dbt?+
It depends on the adapter. dbt-snowflake and dbt-bigquery default to merge. dbt-postgres uses append when no unique_key is defined and delete+insert when one is. Check the configuration page of your adapter before relying on a default.
How does dbt merge incremental work?+
With the merge strategy, dbt inserts rows whose unique_key is not yet in the target table and updates rows whose key already exists. Without a unique_key, merge behaves like append. It can be expensive on large tables because it scans the whole destination table.
Is unique_key required for an incremental model?+
No on most adapters, but without it most adapters append only, so a changed row lands twice. On BigQuery the merge strategy requires a unique_key. The key columns should contain no nulls, or rows may fail to match and duplicate.
How do I force an incremental model to rebuild from scratch?+
Run dbt run with the full refresh flag and select the model. dbt drops the existing table and rebuilds it as on a first run. Adding a trailing + also full-refreshes downstream incremental models.
What does on_schema_change do?+
It sets what dbt does when the model's columns no longer match the target table. It takes 4 values: ignore (the default), fail, append_new_columns and sync_all_columns. With the default, new columns are not added and a removed column makes the run fail.
What is the microbatch incremental strategy?+
Microbatch splits a large time-series model into batches by an event_time column, one query per batch, and can run them in parallel. The dbt documentation lists it for dbt v1.9 or higher.
02 / Why practice

The candidate who gets the offer

  1. 01

    Reading a solution is not the same as writing one

    Every engineer who has frozen on a query they had read a dozen times knows the gap. The only preparation that closes it is producing the answer yourself, under time, before the interview does it for you

  2. 02

    76% of hiring managers reject on the coding task, not the resume

    From HackerRank's 2024 Developer Skills Report. Candidates who look strong on paper still fail the live screen if they haven't done timed, executable practice

  3. 03

    5 problem shapes cover 80% of data engineer loops

    Dedup, sessionization, top-N-per-group, slowly-changing dimensions, partition tricks. Writing the shapes by hand turns the unfamiliar into pattern recognition

Related guides