What you will be able to do
- Name the three factors that decide which incremental strategy fits a model
- Choose between append, merge and delete+insert based on whether the data changes and whether the key can be trusted
- Predict what happens when unique_key is missing, null or not truly unique
- Choose incremental_predicates, insert_overwrite or microbatch when the data volume is large
- Check a strategy against what the adapter supports and the extra configs it needs
Key concept
Incremental strategy — An incremental strategy is the config (incremental_strategy) that controls how an incremental model writes its new and changed rows into the existing table. It can append them, upsert them, delete and reinsert them, or replace whole partitions or time batches. The right choice depends on data volume, how reliable the unique key is, and what the platform supports.
1.Three factors that decide the strategy
An incremental model only transforms the rows that are new or changed since its last run. What happens when those rows reach the existing table is up to the incremental strategy. dbt can append them, upsert them on a key, delete and reinsert them, or replace whole partitions. Whether a model should be incremental in the first place is a separate question, covered under [core materializations](lesson:1.2). This page assumes the answer is yes and asks how it should write.
No strategy is best in every case. Three properties of the dataset and its platform decide the choice:
- Volume: how many rows arrive per run, and how large the destination table already is.
- Key reliability: whether the unique_key really identifies exactly one row and is never null.
- Platform support: which strategies the adapter for your data platform implements.
A strategy can pass on one factor and fail on another. merge handles changing rows well, but it gets slow on a huge table. append is cheap, but it duplicates any row it sees twice. Weigh all three before you pick.
You set the strategy with the incremental_strategy config. It can go in a single model's config block, or apply to every model from dbt_project.yml. A project-wide default makes sense when most models share the same shape. A model whose data behaves differently overrides that default in its own config.
models:
+incremental_strategy: "insert_overwrite"Checkpoint 1 of 8· Check yourself
Which of these is NOT one of the factors that determine how well an incremental strategy fits?
The value of a strategy depends on data volume, how reliable the unique_key is, and platform support. The number of downstream consumers has no effect on how rows are written.
“There are various strategies to implement the concept of incremental materializations. The value of each strategy depends on:”Source: docs.getdbt.com
Sources1
2.Row-level strategies: append, merge and delete+insert
append is the cheapest strategy. It inserts the selected rows and never updates or deletes anything already in the table. That suits data that is never revised, such as raw events. For anything else it is risky, because append never checks whether a row is already there. A record that shows up in two runs is inserted twice, and nothing warns you.
merge inserts rows whose unique_key is new and updates rows whose key already exists. That is SCD type 1 behaviour: a change overwrites the old values and no history is kept. Because it matches on the key, merge also stops duplicates that append would let through. By default it overwrites a matched row completely. Two configs narrow that. merge_update_columns updates only the columns you list, and merge_exclude_columns leaves the columns you list untouched. Excluding a column is useful when an original value, such as when a record was first created, should never be overwritten.
{{
config(
materialized = 'incremental',
unique_key = 'id',
merge_exclude_columns = ['created_at'],
...
)
}}delete+insert first deletes the target rows that match the incoming keys, then inserts the new versions. Every updated record is replaced in full, so a partial update can never happen. It can be less efficient on large datasets. Two situations still make it the right choice: the key is not truly unique, or the platform has no merge. dbt itself takes this route on warehouses without merge statements, running a delete followed by an insert.
None of these strategies keeps history. If the requirement is SCD type 2, with every past version of a row preserved, use a snapshot rather than delete+insert. Snapshots are covered in [snapshots in YAML](lesson:1.10).
| Strategy | Existing rows | Uses unique_key | Choose it when |
|---|---|---|---|
| append | Never touched; duplicates are possible | No | Rows are never updated and processing should be cheap |
| merge | Matched rows are updated, new rows inserted (SCD1) | Yes | Rows change and the key is reliable |
| delete+insert | Matched rows are deleted, then reinserted in full | Yes | The key is not truly unique, or merge is unsupported |
Checkpoint 2 of 8· Match them up
Match each requirement to the tool that meets it
Tap a term, then the definition that fits it.
append adds rows, merge upserts in SCD1 style, and delete+insert replaces matched rows in full. Only snapshots keep history.
“For SCD2, use dbt snapshots, not delete+insert.”Source: docs.getdbt.com
Checkpoint 3 of 8· Exam question
A clickstream events model ingests roughly 500 million new rows per day from an event queue. Each row is immutable once written, there is no reliable natural key to deduplicate on, and the team already dedupes on `event_id` at query time downstream. Which incremental strategy best fits this model?
Correct answer: A — Configure `incremental_strategy: append` so dbt inserts each new batch of rows directly without attempting to delete or match existing records.
- A. Append is correct because it inserts every incoming row without a match step, which fits an immutable, high-volume stream where occasional duplicates are already handled downstream and a matching scan would only add cost.
- B. Merge is incorrect here because without a genuinely reliable key it either behaves like append anyway or wastes a full destination-table scan on every run, adding cost the team does not need.
- C. Delete+insert is incorrect because it assumes the unique key is trustworthy enough to safely delete matching rows first, which contradicts the stated lack of a reliable key for this stream.
- D. Insert_overwrite is incorrect because it is built for partition-level corrections to existing data, not for simple continuous append-only ingestion of new immutable rows.
3.When the unique key is missing, null or not unique
On most adapters, a missing key does not cause an error. If unique_key is left out, merge and delete+insert fall back to append. The run succeeds and duplicates build up without any warning. BigQuery is the exception for merge: a merge model there must have a key, and leaving it out leads to ambiguous or failing behaviour. insert_overwrite is different again, because it never uses unique_key. It works on whole partitions, not individual rows.
A key can also exist and still be unreliable. If any key column contains nulls, dbt cannot match those rows to existing ones, and the model creates duplicates. You can fix this by wrapping each key column in coalesce, or by building a single-column surrogate key with dbt_utils.generate_surrogate_key. When the grain needs several columns, pass them as a list, for example unique_key = ['user_id', 'session_number']. Avoid a string expression such as concat(user_id, session_number): with a list, dbt can template the columns correctly for each database.
Duplicate key values cause a different failure. If a key appears on more than one row, either in the existing table or in the incoming rows, the run may fail, depending on the database and strategy. The guard is a unique and not_null test on the key in the source or staging layer. If the grain really allows many rows per key value, such as many rows per date_day, merge is the wrong tool. Use delete+insert keyed on that column, so every row for an incoming day is replaced together.
Checkpoint 4 of 8· Fill the gap
Many rows share each date_day, and every run must fully replace all rows for the incoming days. Which strategy completes this config?
{{
config(
materialized='incremental',
unique_key='date_day',
incremental_strategy=' ? ',
...
)
}}delete+insert removes every target row for the incoming date_day values and reinserts them, which works even though the key is not unique per row. merge expects one row per key, append never replaces anything, and insert_overwrite ignores unique_key.
Source: docs.getdbt.com4.Large tables, partitions and late-arriving data
merge is the usual starting point, but its cost grows with the destination table. To decide what to update and what to insert, it scans the entire target. On a small or medium table that cost doesn't matter. On a fact table of billions of rows, every run pays for that scan.
incremental_predicates keeps merge usable at that scale. It adds SQL conditions that limit how much of the existing table the merge reads. DBT_INTERNAL_DEST refers to the target table and DBT_INTERNAL_SOURCE to the new rows. dbt does not check the syntax of these expressions, so a typo only shows up at run time. Clustering the table on the same column helps the warehouse skip data it doesn't need.
models:
- name: my_incremental_model
config:
materialized: incremental
unique_key: id
# this will affect how the data is stored on disk, and indexed to limit scans
cluster_by: ['session_start']
incremental_strategy: merge
# this limits the scan of the existing table to the last 7 days of data
incremental_predicates: ["DBT_INTERNAL_DEST.session_start > dateadd(day, -7, current_date)"]When the table is partitioned by date or another key, insert_overwrite often fits better than merge. It replaces only the partitions the new data touches, as whole units, and leaves the rest of the table alone. That makes it a good way to refresh recent or corrected data without rebuilding the full table. It needs no row key at all.
microbatch is built for very large time-series tables, such as multi-year clickstream, IoT or point-of-sale facts. It splits each run into time-based batches, defined by an event_time column, at a grain such as hourly or daily. Each batch can be retried on its own, which makes backfills safer. Choose it when:
- tables reach billions of rows - backfills are slow and risky with the other strategies - late-arriving data is common.
Don't choose it when there is no reliable event_time column, or when you need more control over the incremental logic. The batch configs are covered in [microbatch models](lesson:1.14).
Late-arriving data needs handling whichever strategy you choose. A merge model that filters on event_ts > max(event_ts) will skip rows that land after a later row has already been loaded. Widen the filter with a lookback window, for example one hour before the maximum. microbatch has this built in through lookback, which reprocesses the batches just before the latest one and defaults to 1.
Checkpoint 5 of 8· Check yourself
A clickstream fact table holds four years of history and billions of rows. It has a reliable event timestamp, late events are common, and backfills keep timing out. Which strategy fits best?
Huge time-series tables with multi-year history, painful backfills and late data are exactly what microbatch is for. A reliable event_time column is its prerequisite, and this table has one.
“Use microbatch for massive fact tables (clickstream, IoT, point-of-sale) with multi-year history.”Source: docs.getdbt.com
Checkpoint 6 of 8· Exam question
A Snowflake-backed model ingests sensor readings tagged with `reading_time` from millions of devices. Readings for a given day can keep arriving up to three days after that day closes, and the team wants a failed daily load to be retried on its own rather than rerunning the whole model. Which configuration best satisfies these requirements?
Correct answer: A — Set `incremental_strategy: microbatch`, `event_time: reading_time`, `batch_size: day`, and `lookback: 3` so each day is its own retryable batch and late readings are recaptured.
- A. Microbatch with a three-day lookback is correct because dbt automatically reprocesses the last few day-batches on every run and can retry a single failed batch without touching the others.
- B. This merge setup is incorrect because the manual date filter reprocesses the whole model as one query on every run, so a failure forces a full rerun instead of a per-day retry.
- C. This insert_overwrite setup is incorrect because a single run still overwrites the whole three-day window as one operation, so it does not give the independent per-batch retry the team wants.
- D. This delete+insert setup is incorrect because rerunning the entire model every three days processes far more data than necessary and still has no per-day retry granularity.
5.Matching the strategy to the adapter
A strategy is only an option if the adapter implements it. dbt-databricks and dbt-snowflake support all five built-in strategies: append, merge, delete+insert, insert_overwrite and microbatch. Microsoft Fabric does not support microbatch yet. If a platform has no merge, delete+insert is the fallback for full row replacement.
microbatch does not work the same way on every adapter. On each one, dbt uses the most efficient way available to replace a full batch. That changes what you have to configure. On dbt-postgres, microbatch runs as a merge, so it needs a unique_key to match rows. On BigQuery and Spark, it runs as insert_overwrite, so it needs partition_by.
| Adapter | Underlying mechanism | Extra required config |
|---|---|---|
| dbt-postgres | merge | unique_key |
| dbt-redshift | delete+insert | None |
| dbt-snowflake | delete+insert | None |
| dbt-bigquery | insert_overwrite | partition_by |
| dbt-spark | insert_overwrite | partition_by |
| dbt-databricks | replace_where | None |
In practice, start with merge, because it suits most use cases. If the source tables receive frequent updates rather than only appends, move to the warehouse's native change data capture. On Snowflake that means Streams, read by a merge model. Use microbatch only once the scale is massive.
| Pattern | Best for | Key benefit |
|---|---|---|
| merge from append-only | Most standard use cases | Simple, widely understood |
| CDC with Streams | Tables with frequent updates | Efficient change capture |
| Microbatch | Massive time-series tables | Safe backfills, late-data handling |
Checkpoint 7 of 8· Check yourself
A microbatch model on dbt-postgres cannot match incoming batch rows to rows already in the table. Which config is it most likely missing?
On Postgres, microbatch runs as a merge, and a merge needs unique_key to identify which rows to match. partition_by is the extra requirement on BigQuery and Spark, not Postgres.
“unique_key is required because dbt-postgres microbatch uses the merge strategy”Source: docs.getdbt.com
Checkpoint 8 of 8· Exam question
A team is deciding between `merge` and `delete+insert` for a finance transactions model on Snowflake. The table has a reliable `transaction_id` unique key, receives frequent partial-column updates (for example a `status` field changes after the row already loaded), and is queried heavily throughout the business day. Which of the following statements should inform the decision? (Select all that apply.)(Select 3)
Correct answers: A, B, E — `merge` can be configured with `merge_update_columns` to update only the `status` column, avoiding unnecessary writes to columns that did not change.; `delete+insert` removes and reinserts the full row for any matching `transaction_id`, so every column is rewritten even when only `status` changed.; On large tables `merge` can become expensive because it scans the destination table to match incoming rows by `transaction_id`, which is worth weighing against daytime query concurrency.
- A. This is correct because `merge_update_columns` narrows the update to the listed columns, which matches the scenario of a single `status` field changing after load.
- B. This is correct because delete+insert operates at the row level, so it always removes and reloads every column of a matched row regardless of how many columns actually changed.
- C. This is incorrect because merge is an incremental-materialization strategy that runs regardless of view versus table logic; Snowflake's MERGE statement is not tied to a view materialization.
- D. This is incorrect because delete+insert must have a `unique_key` to know which existing rows to delete before inserting; without one it cannot target the matching rows.
- E. This is correct because merge's key-matching step scans the destination table, and on a large, heavily queried table that scan cost and any resulting contention is a real factor to weigh.
- F. This is incorrect because insert_overwrite replaces whole partitions rather than matching and updating individual rows, so it does not perform in-place row updates.
Exam traps
Each one states something that sounds right. Open it to see what is actually true.
1.A merge model with no unique_key fails, so a missing key will always be noticed.Why is that wrong?
On most adapters, merge without a unique_key behaves like append. The run succeeds and duplicates collect without any warning. BigQuery is the exception.
Covered in When the unique key is missing, null or not unique
2.delete+insert keeps the old version of a row, so it gives you SCD type 2 history.Why is that wrong?
delete+insert overwrites rows and does not track history. SCD2 needs snapshots.
Covered in Row-level strategies: append, merge and delete+insert
3.merge stays cheap however large the table gets, because only the new rows are processed.Why is that wrong?
merge scans the whole destination table to decide what to update or insert, so its cost grows with the table. Use incremental_predicates, insert_overwrite or microbatch at large scale.
4.A microbatch config that works on Snowflake will work unchanged on Postgres or BigQuery.Why is that wrong?
Each adapter implements microbatch differently. Postgres also needs unique_key, and BigQuery and Spark also need partition_by.
Covered in Matching the strategy to the adapter
Sources
Every claim above is drawn from one of these pages, quoted as it was written on the date shown.
- 1.
“The reliability of your unique_key.”
↩︎ Three factors that decide the strategy“The incremental_strategy config can either be defined in specific models or for all models in your dbt_project.yml file:”
↩︎ Three factors that decide the strategy“If the same record appears multiple times in the source, it will be inserted again, potentially resulting in duplicate rows.”
↩︎ Row-level strategies: append, merge and delete+insert“mirroring the logic of SCD1, where changes are overwritten rather than historically tracked.”
↩︎ Row-level strategies: append, merge and delete+insert“dbt will update only the columns specified by the config, and keep the previous values of other columns.”
↩︎ Row-level strategies: append, merge and delete+insert“can be useful when a unique_key isn't truly unique or when merge is unsupported”
↩︎ Row-level strategies: append, merge and delete+insert“incremental_predicates is an advanced use of incremental models, where data volume is large enough to justify additional investments in performance.”
↩︎ Large tables, partitions and late-arriving data“useful for refreshing recent or corrected data without full table rebuilds.”
↩︎ Large tables, partitions and late-arriving data“Incremental strategies for materializations optimize performance by defining how to handle new and changed data.”
↩︎ Key concept“Note, if you use merge without specifying a unique_key, it behaves like the append strategy.”
↩︎ Exam trap 1“For SCD2, use dbt snapshots, not delete+insert.”
↩︎ Exam trap 2“It can be expensive for large tables because it scans the entire destination table to determine what to update or insert.”
↩︎ Exam trap 3“There are various strategies to implement the concept of incremental materializations. The value of each strategy depends on:”
↩︎ Checkpoint“For SCD2, use dbt snapshots, not delete+insert.”
↩︎ Checkpoint - 2.
“On warehouses that do not support merge statements, a merge is implemented by first using a delete statement”
↩︎ Row-level strategies: append, merge and delete+insert - 3.
“For incremental_strategy = merge, you must provide a unique_key; leaving it out leads to ambiguous or failing behavior.”
↩︎ When the unique key is missing, null or not unique“the adapter will fall back to using incremental_strategy: append.”
↩︎ Prediction - 4.
“the insert_overwrite strategy does not use unique_key, because it operates on partitions of data rather than individual rows.”
↩︎ When the unique key is missing, null or not unique“Columns used in this way should not contain any nulls, or the incremental model may fail to match rows and generate duplicate rows.”
↩︎ When the unique key is missing, null or not unique - 5.https://docs.getdbt.com/best-practices/how-we-handle-cdc/2-choosing-incremental-or-snapshotsOfficial docs
“Test that the source or staging unique_key is unique and not null.”
↩︎ When the unique key is missing, null or not unique - 6.
“Consider other strategies for use cases such as not having a reliable event_time column or if you want more control over the incremental logic.”
↩︎ Large tables, partitions and late-arriving data“Process X batches prior to the latest bookmark to capture late-arriving records.”
↩︎ Large tables, partitions and late-arriving data“dbt's microbatch strategy uses the most efficient mechanism available for "full batch" replacement on each adapter.”
↩︎ Matching the strategy to the adapter“Some adapters require additional configurations for the microbatch strategy.”
↩︎ Exam trap 4“unique_key is required because dbt-postgres microbatch uses the merge strategy”
↩︎ Checkpoint - 7.https://docs.getdbt.com/best-practices/how-we-handle-real-time-data/2-incremental-patternsOfficial docs
“Consider adding a lookback window (for example, event_ts > max(event_ts) - interval '1 hour') to handle late-arriving data.”
↩︎ Large tables, partitions and late-arriving data“Reach for pattern 3 (Microbatch) when dealing with massive scale.”
↩︎ Matching the strategy to the adapter“Use microbatch for massive fact tables (clickstream, IoT, point-of-sale) with multi-year history.”
↩︎ Checkpoint