In dbt, how do the append, merge, delete+insert and insert_overwrite incremental strategies differ?
answer
- four ways to write into the existing table
- one matches rows, one matches partitions
- one is fast and unsafe on rerun
- what makes the write idempotent
- MERGE versus delete-then-insert versus partition swap
basics
~20 sIn dbt, append blindly inserts new rows; merge matches on unique_key and updates in place; delete+insert removes matching keys then reinserts; insert_overwrite replaces whole partitions present in the run. Which are available depends on the adapter.
solid answer
~40 s`incremental_strategy` decides how dbt writes candidate rows into an existing table. **append** simply inserts them — fastest, but any reprocessed row duplicates. **merge** issues a `MERGE` joining on `unique_key`, updating matched rows and inserting the rest, which makes reruns idempotent at row grain; it is the default on Snowflake, BigQuery and Databricks Delta. **delete+insert** deletes the target rows whose keys appear in the candidate set and then inserts them all — same net effect as merge, done as two statements, useful where merge is unsupported or slow. **insert_overwrite** ignores keys and replaces entire partitions that the run produced, which is the cheapest idempotent option on partitioned tables in BigQuery and Spark. Availability and defaults are adapter-specific — Postgres and Redshift default to append — so I check the adapter docs rather than assuming.
code
sql · 16 lines-- BigQuery: replace whole days the run recomputes
{{ config(
materialized='incremental',
incremental_strategy='insert_overwrite',
partition_by={'field': 'event_date', 'data_type': 'date'}
) }}
select
date(event_at) as event_date,
user_id,
count(*) as events
from {{ ref('stg_events') }}
{% if is_incremental() %}
where date(event_at) >= date_sub(current_date(), interval 3 day)
{% endif %}
group by 1, 2go deeper
Know that the strategy is a config on incremental models and that append simply inserts while merge matches rows on a key.
Explain the SQL each one generates and the role unique_key plays in merge and delete+insert but not in append or insert_overwrite.
Show you can pick per table and defend it: partition-complete runs favour insert_overwrite, restating sources need merge, and merge cost on huge targets is fixed with predicates that let the engine prune.
Own the standard across a project — which strategies are permitted on which adapters, how each model documents its idempotency contract, and how full refreshes bound the drift that any incremental strategy accumulates.
## Where the strategy sits An incremental dbt run happens in two parts. Your model SQL, filtered by the `is_incremental()` block, produces a set of candidate rows into a temporary relation. Then dbt executes a second statement that combines that relation with the existing target table. `incremental_strategy` chooses that second statement: ```sql {{ config( materialized='incremental', incremental_strategy='merge', unique_key='order_id' ) }} ``` The strategy is where idempotency lives. Your filter decides *what* gets reprocessed; the strategy decides whether reprocessing the same row twice corrupts the table. ## append Plain `insert into target select * from candidates`. No key matching, no scan of the target, nothing to go wrong at write time — and no protection whatsoever. Rerun the model after a partial failure, widen a lookback window, or receive a restated record, and you get a second copy. It is the right choice for genuinely immutable event streams where the filter guarantees each row is emitted exactly once, and for staging layers where downstream models deduplicate anyway. It is the wrong choice anywhere a rerun is plausible, which in practice is most places. ## merge dbt generates a `MERGE INTO target USING candidates ON target.<unique_key> = candidates.<unique_key>` with a `WHEN MATCHED THEN UPDATE` and a `WHEN NOT MATCHED THEN INSERT`. Rerunning the same rows updates them in place rather than duplicating them, which is what makes the model safe to re-execute. Caveats worth naming in an interview: - Without a `unique_key`, dbt's merge has no matched branch and behaves as insert-only. - The merge joins candidates against the whole target on the key. On a huge fact table that scan can dominate the run; `incremental_predicates` lets you add conditions that restrict the target side so the engine can prune partitions or clusters. - Duplicate keys within the candidate set produce a nondeterministic merge — an error on some engines, an arbitrary winner on others. - It never deletes. Rows removed at source stay in the target. Merge is the default on Snowflake, BigQuery and Databricks with Delta. ## delete+insert Two statements: `delete from target where unique_key in (select unique_key from candidates)`, then `insert`. Semantically close to merge, and the practical option on adapters where merge is unavailable or where a bulk delete plus insert plans better than a row-matching merge — Redshift and Postgres workloads often land here. Its sharper edge is what happens between the two statements. In a warehouse without a transaction wrapping both, there is a window where rows have been deleted and not yet reinserted, and a reader can see missing data. Adapters differ in how they wrap this; it matters for tables that are queried continuously. ## insert_overwrite Identity is by **partition**, not by row key. dbt determines which partitions the candidate rows fall into and replaces those partitions wholesale, dropping whatever was there before. There is no key matching and no target-wide join, so it is typically the cheapest idempotent write on a large partitioned table. It requires the table to be partitioned — `partition_by` on BigQuery, a partitioned table on Spark or Databricks — and it changes the correctness contract: rerunning a day replaces that entire day. That is exactly right for a pipeline where each run recomputes complete days, and exactly wrong for a model that emits a partial slice of a day, because the missing rows are silently deleted. On BigQuery it comes in two shapes: a dynamic mode that derives the partitions from the candidate rows, and a static mode where you declare the partitions to replace via a `partitions` config, which lets the engine prune the overwrite scope precisely. ## Choosing A workable decision path: 1. Is the table partitioned by the same grain the run recomputes, and does each run produce complete partitions? → `insert_overwrite`, cheapest and robustly idempotent. 2. Otherwise, is there a reliable row identifier and does the source restate rows? → `merge` (or `delete+insert` if merge is unsupported or plans badly). 3. Is the stream strictly immutable, append-only, with a filter that cannot re-emit a row, and no rerun risk? → `append`. Write the choice down in the model, because the failure modes are silent: append duplicates, merge that never deletes, insert_overwrite that erases a partial partition. None of them raise an error at run time. ## Adapter reality Strategy support is not uniform. Snowflake supports append, merge and delete+insert; BigQuery supports merge and insert_overwrite; Spark and Databricks support append, insert_overwrite and merge on table formats that allow it; Postgres and Redshift default to append with delete+insert available. Newer dbt versions add a `microbatch` strategy (dbt 1.9) that splits an incremental run into time-bounded batches driven by an `event_time` config. Always confirm against the adapter's own documentation rather than carrying assumptions from one warehouse to another.
- When would you prefer delete+insert over merge in dbt if both are available?When the adapter's merge plans poorly — a row-matching merge across a very large target can be slower than a bulk delete restricted by partition plus a straight insert. The trade-off is the window between the two statements, during which readers can see rows missing unless the adapter wraps both in a transaction.
- What breaks if a dbt model using insert_overwrite emits only part of a day's rows?The whole partition for that day is replaced by the partial set, so every row you did not emit is deleted. insert_overwrite is only safe when each run recomputes complete partitions — a filter that picks up just newly-arrived rows within a day is incompatible with it.
- How do you stop a dbt merge from scanning the entire target table on every run?Add predicates that restrict the target side of the merge join so the engine can prune. dbt's `incremental_predicates` config injects conditions such as a date floor on the target relation, which lets partition or cluster pruning apply. Without it the merge joins candidates against all history on the key.
saying these in an interview costs you the question
- Assuming merge is available and default on every warehouse
- Using append and expecting reruns to be safe
- Believing merge deletes rows that disappeared from the source
- Using insert_overwrite when a run emits partial partitions
- Treating insert_overwrite as key-based rather than partition-based