В предыдущих уроках мы изучили концепцию incremental и роль unique_key. Теперь — детали реализации: какой именно DDL/DML dbt отправляет в warehouse при инкрементальном run.
Это управляется параметром incremental_strategy. На DuckDB доступны три варианта: append, delete+insert, merge. Каждая со своими гарантиями, ограничениями и performance.
append: самая простая, без updates
Стратегия append просто INSERT’ит новые строки. Не делает никакого дедупа. Это значит, если в источнике уже есть строка с тем же ключом, что и в таблице, она запишется как дубликат.
{{ config(
materialized='incremental',
incremental_strategy='append'
) }}
SELECT *
FROM {{ source('analytics', 'page_views') }}
{% if is_incremental() %}
WHERE event_time > (SELECT MAX(event_time) FROM {{ this }})
{% endif %}
Скомпилируется в:
INSERT INTO target.page_views
SELECT * FROM source.page_views
WHERE event_time > (SELECT MAX(event_time) FROM target.page_views);
Никаких DELETE, MERGE — только INSERT. unique_key игнорируется в этой стратегии.
Когда append лучший выбор:
- Append-only данные (event logs, page views, transactions без updates).
- Каждая строка в источнике уникальна и никогда не меняется.
- Производительность критична — INSERT самая дешёвая операция.
Когда append плохой выбор:
- Источник может обновлять строки (orders с changing status).
- В дельте могут быть повторы из-за late-arriving events.
delete+insert: дедупликация через DELETE
Стратегия delete+insert работает в два шага:
- DELETE FROM target WHERE unique_key IN (новые ключи) — удаляет существующие строки с этими ключами.
- INSERT INTO target (новые данные) — вставляет новые строки.
В результате старые версии удалены, новые вставлены — эффект upsert.
{{ config(
materialized='incremental',
incremental_strategy='delete+insert',
unique_key='order_id'
) }}
SELECT *
FROM {{ source('jaffle_shop', 'raw_orders') }}
{% if is_incremental() %}
WHERE updated_at > (SELECT MAX(updated_at) FROM {{ this }})
{% endif %}
Скомпилируется в (упрощённо):
-- Шаг 1
DELETE FROM target.orders
WHERE order_id IN (
SELECT order_id FROM source.orders
WHERE updated_at > (SELECT MAX(updated_at) FROM target.orders)
);
-- Шаг 2
INSERT INTO target.orders
SELECT * FROM source.orders
WHERE updated_at > (SELECT MAX(updated_at) FROM target.orders);
Когда delete+insert хороша:
- Нужен upsert (обновление + добавление).
- Warehouse не поддерживает MERGE (DuckDB до 1.4).
- Простая логика, легко дебагать.
Минусы:
- Две операции вместо одной — не атомарно, между DELETE и INSERT короткое окно с потерянными строками.
- На большом числе ключей DELETE WHERE col IN (…) может быть медленным.
delete+insert — стратегия по умолчанию на dbt-duckdb для incremental с unique_key (до версии где появился merge). На DuckDB она работает быстро для большинства задач. На некоторых warehouse есть свои дефолты — например, BigQuery предпочитает merge.
merge: атомарный upsert (DuckDB 1.4+)
Стратегия merge использует SQL-конструкцию MERGE — атомарную операцию upsert, появившуюся в DuckDB 1.4.
{{ config(
materialized='incremental',
incremental_strategy='merge',
unique_key='order_id'
) }}
SELECT *
FROM {{ source('jaffle_shop', 'raw_orders') }}
{% if is_incremental() %}
WHERE updated_at > (SELECT MAX(updated_at) FROM {{ this }})
{% endif %}
Скомпилируется в:
MERGE INTO target.orders AS target
USING (
SELECT * FROM source.orders
WHERE updated_at > (SELECT MAX(updated_at) FROM target.orders)
) AS source
ON target.order_id = source.order_id
WHEN MATCHED THEN UPDATE SET ...
WHEN NOT MATCHED THEN INSERT ...;
Одна операция — атомарная. Преимущества:
- Атомарность: либо всё применилось, либо ничего. Без промежуточного состояния.
- Часто быстрее delete+insert на больших данных.
- Понятная семантика SQL.
Минусы:
- Требует DuckDB 1.4+. На более старых — ошибка компиляции.
- На очень больших таблицах может потребовать настройки memory.
Если используешь dbt-duckdb с merge и при run получаешь “Parser Error: Unknown statement type MERGE” — у тебя DuckDB старее 1.4. Обнови duckdb (pip install -U duckdb) или используй стратегию delete+insert.
Сравнение стратегий
Append — самый быстрый, но без updates. delete+insert — upsert через два шага, не атомарный. merge — атомарный upsert, нужен DuckDB 1.4+. Выбор зависит от формы данных и версии DuckDB.
Когда какую стратегию
append — выбирай, когда:
- Данные append-only (events, transactions).
- Не нужно обновлять существующие строки.
- Хочешь максимальной производительности.
delete+insert — выбирай, когда:
- Нужен upsert, и DuckDB менее 1.4.
- Хочешь явно видеть DELETE+INSERT в логах для дебага.
- Объёмы небольшие, не критично.
merge — выбирай, когда:
- Нужен upsert, и DuckDB 1.4+ доступен.
- Хочешь атомарность.
- Большие объёмы, важна производительность.
В сомнениях: на свежем DuckDB используй merge. Это стандарт индустрии (Snowflake/BigQuery тоже предпочитают merge).
—full-refresh: общий для всех стратегий
dbt run --full-refresh обходит любую стратегию: dbt делает DROP + CREATE TABLE AS SELECT с полным запросом без {'{% if is_incremental() %}'} блока. После full-refresh структура таблицы возможно поменялась — этот режим часто используется при изменении SQL модели.
dbt run --select fact_orders --full-refresh
Подходит как для append, так и для merge — стратегия применяется только к инкрементальным runs.
Конфигурация incremental_strategy
Можно задать в самой модели:
{{ config(
materialized='incremental',
incremental_strategy='merge',
unique_key='order_id'
) }}
Или в dbt_project.yml как default для всех incremental в папке:
models:
jaffle_shop:
marts:
+materialized: incremental
+incremental_strategy: merge
config в SQL переопределяет YAML, как и для других параметров.
Что произойдёт, если стратегия не указана
Если incremental_strategy не указан:
- С
unique_key— dbt берёт default для адаптера (на dbt-duckdb обычноdelete+insert, на новых версиях возможноmerge). - Без
unique_key— dbt берётappend.
Лучше явно указывать стратегию, чтобы не зависеть от defaults адаптера.
Дополнительные параметры стратегий
merge_exclude_columns (для merge) — список колонок, которые НЕ обновлять при MATCHED:
{{ config(
materialized='incremental',
incremental_strategy='merge',
unique_key='order_id',
merge_exclude_columns=['inserted_at']
) }}
Полезно для аудит-колонок: inserted_at должна сохранять время первого insert, а не каждого update.
merge_update_columns (для merge) — наоборот, явный список колонок для UPDATE:
{{ config(
materialized='incremental',
incremental_strategy='merge',
unique_key='order_id',
merge_update_columns=['status', 'updated_at']
) }}
Используется реже, но полезно для строгого контроля.
DuckDB-специфика: концентрированный список
- append всегда работает — даже на самых старых DuckDB.
- delete+insert работает с любой версии, активно использовался до 1.4.
- merge требует DuckDB 1.4+ — pip install -U duckdb обновит.
- microbatch (1.9+) НЕ ПОДДЕРЖИВАЕТ unique_key в dbt-duckdb. Это ограничение адаптера, на других warehouse поддерживается. Об этом — в следующем уроке.
- Single writer per file: incremental на одной DuckDB-файле работает корректно, конкуррентный dbt run не нужен.
Пример: переход append -> merge
Допустим, у тебя fact_orders на стратегии append. Поняв, что данные могут обновляться, решил перейти на merge:
-- Было
{{ config(
materialized='incremental',
incremental_strategy='append'
) }}
-- Стало
{{ config(
materialized='incremental',
incremental_strategy='merge',
unique_key='order_id'
) }}
После изменения нужен --full-refresh, чтобы:
- Убрать накопленные дубли из таблицы.
- Пересчитать с merge-стратегией с нуля.
dbt run --select fact_orders --full-refresh
После этого следующие runs будут уже инкрементальными с merge-стратегией.
Команды для отладки
Посмотреть, какая стратегия применена:
dbt list --select fact_orders --output json | jq '.[] | {name, config: .config | {materialized, incremental_strategy, unique_key}}'
Output:
{
"name": "fact_orders",
"config": {
"materialized": "incremental",
"incremental_strategy": "merge",
"unique_key": "order_id"
}
}
Скомпилированный SQL в target/run/<project>/models/.../fact_orders.sql покажет финальный DDL — DELETE/INSERT или MERGE.
Попробуй сам
Создай incremental-модель с разными стратегиями. Создай models/marts/test_append.sql:
{{ config(
materialized='incremental',
incremental_strategy='append'
) }}
SELECT * FROM {{ source('jaffle_shop', 'raw_orders') }}
{% if is_incremental() %}
WHERE updated_at > (SELECT MAX(updated_at) FROM {{ this }})
{% endif %}
Запусти несколько раз с разными настройками. Проверь:
SELECT COUNT(*), COUNT(DISTINCT order_id) FROM test_append;
Если COUNT > COUNT DISTINCT — это дубли из-за append.
Создай models/marts/test_merge.sql с теми же данными, но incremental_strategy='merge' и unique_key='order_id'. Запусти несколько раз. Должны быть равны COUNT и DISTINCT.
Сравни target/run/…/test_append.sql и target/run/…/test_merge.sql — увидишь разный финальный SQL.
Что мы поняли
Incremental strategies определяют способ применения дельты: append (только INSERT, без дедупа), delete+insert (DELETE matching + INSERT all, не атомарный, default для DuckDB до 1.4), merge (атомарный MERGE, требует DuckDB 1.4+). Стратегия выбирается через incremental_strategy параметр или в dbt_project.yml. На свежем DuckDB merge — рекомендуемый дефолт для upsert’ов. —full-refresh обходит любую стратегию.
В следующем уроке — microbatch, специальная стратегия для time-based incremental, появилась в dbt 1.9.
delete+insert vs merge: deep dive на уровне сгенерированного SQL Data-modifying CTE: INSERT/UPDATE/DELETE внутри WITH