В прошлом модуле мы понимали, что table-материализация при каждом dbt run пересоздаёт таблицу с нуля. Это работает на малых данных, но становится медленным, когда таблица растёт. Если у тебя 100M строк events, пересчитывать всё каждые 15 минут — это часы compute и большие счета за warehouse.
Решение — incremental materialization. dbt при первом run создаёт таблицу полностью, при следующих runs — обрабатывает только новые/изменённые строки (дельту). Это самая важная и самая сложная materialization, которую должен освоить junior’у.
Главная идея incremental
dbt запоминает, что в таблице уже есть данные, и при следующем run обрабатывает только то, чего ещё нет. Конкретный механизм зависит от стратегии (append/merge/delete+insert — следующий урок), но базовая логика общая:
Table — каждый run пересчитывает всё. Incremental — первый run строит полностью, последующие добавляют только дельту. На больших таблицах разница в десятки и сотни раз по времени.
На таблице 100M строк, где каждый час прибавляется 1M:
- table: каждый dbt run — 100M+. На 10 запусках в день = 1B обработанных строк.
- incremental: первый run — 100M, дальше по 1M. На 10 запусках в день = 109M обработанных строк. На порядок дешевле.
Минимальный пример incremental-модели
Здесь и в следующих уроках встретится Jinja-синтаксис: {% if ... %} для условий и {{ ... }} для подстановки значений. Если впервые видишь — это шаблонизатор, который выполняется перед SQL и подменяет код. Полностью разбираем в модуле 10. Сейчас просто принимай как магию.
Простейшая incremental модель для таблицы orders с колонкой updated_at:
{{ config(
materialized='incremental',
unique_key='order_id'
) }}
SELECT *
FROM {{ source('jaffle_shop', 'raw_orders') }}
{% if is_incremental() %}
WHERE updated_at > (SELECT MAX(updated_at) FROM {{ this }})
{% endif %}
Разберём по строкам:
materialized='incremental'— говорит dbt использовать incremental-материализацию.unique_key='order_id'— колонка, по которой dbt различает уникальные записи. Нужна для большинства стратегий (merge, delete+insert).SELECT * FROM source(...)— основной запрос, который выполняется при первом run.{'{% if is_incremental() %}'}— Jinja-условие, которое истинно только при инкрементальном run. То, что внутри блока, добавляется к запросу только когда таблица уже существует и это не —full-refresh.WHERE updated_at > (SELECT MAX(updated_at) FROM {'{{'}'} this {'}}'}— фильтр, который оставляет только новые строки.{'{{'} this {'}}'}— ссылка на саму таблицу, которая уже существует в warehouse.
Что такое is_incremental()
is_incremental() — это macro, которая возвращает True, если выполняются четыре условия одновременно:
- Это инкрементальная модель (materialized=‘incremental’);
- Таблица уже существует в warehouse;
- Это не первый run (нет
--full-refreshфлага); - Используется не unit test.
Если хотя бы одно условие False — is_incremental() вернёт False, и блок {'{% if is_incremental() %}'} пропустится.
Первый run или --full-refresh: is_incremental()=False, выполняется полный SELECT с CREATE TABLE. Последующие run: is_incremental()=True, выполняется только дельта-фильтр с INSERT/MERGE.
--full-refresh: спасательный круг
Иногда нужно пересчитать incremental-таблицу с нуля. Причины:
- Изменилась логика модели (например, новый расчёт или новая колонка).
- Заметили баг — таблица содержит некорректные строки.
- Нужно “перезагрузить” историю после изменения raw-данных.
Команда:
dbt run --select fact_orders --full-refresh
При запуске с --full-refresh dbt игнорирует существующую таблицу: делает DROP, выполняет полный SELECT без WHERE фильтра, создаёт новую.
—full-refresh на больших таблицах — операция дорогая (тот же время, что dbt run на таблице с нуля). Не используй его в обычном расписании. Это инструмент для разработки и аварийного восстановления, не для регулярных runs.
Жизненный цикл: что ожидать
Пошаговый сценарий типичной incremental-модели:
Шаг 1: первый раз. Запускаешь dbt run --select fact_orders. dbt видит, что таблицы нет -> is_incremental()=False -> {'{% if is_incremental() %}'} блок пропускается -> выполняется полный SELECT -> CREATE TABLE. Таблица создаётся со всеми историческими данными.
Шаг 2: следующий run через час. Запускаешь dbt run --select fact_orders. dbt видит, что таблица есть -> is_incremental()=True -> блок выполняется -> WHERE фильтр оставляет только строки с updated_at > max(updated_at в таблице) -> результат пишется через INSERT/MERGE/etc.
Шаг 3: разработка. Меняешь SQL модели — добавляешь новую колонку. Запускаешь dbt run. Получаешь ошибку: “Schema mismatch: column X exists in model but not in target table”. Решение: dbt run --full-refresh, чтобы пересоздать с правильной схемой.
Шаг 4: восстановление. Загрузчик сломался и записал плохие данные. Ты исправил raw. Чтобы убрать плохие данные из incremental-таблицы — dbt run --full-refresh. Полный пересчёт со свежей raw.
Параметр on_schema_change
Когда меняешь схему модели (добавляешь колонку), incremental может конфликтовать с существующей таблицей. Параметр on_schema_change управляет поведением:
{{ config(
materialized='incremental',
unique_key='order_id',
on_schema_change='fail'
) }}
Варианты:
'fail'(default) — упасть с ошибкой при изменении схемы. Безопасно: ничего не сломается.'ignore'— игнорировать новые/удалённые колонки. Старые данные остаются как есть.'append_new_columns'— добавить новые колонки в существующую таблицу. Старые строки получат NULL в новых колонках.'sync_all_columns'— полностью синхронизировать схему (добавить и удалить).
В большинстве случаев 'fail' — правильный default. Если хочешь добавить колонку, делай это явно через --full-refresh или вручную меняй DDL.
Schema change — частая проблема в первые недели разработки incremental. Если планируешь часто менять SQL — на стадии разработки оставь materialized=‘table’. Когда логика стабилизируется и таблица растёт — переключи на incremental.
Что нельзя использовать в incremental
Несколько ловушек:
1. Оконные функции по всей истории. Если в SELECT написано ROW_NUMBER() OVER (ORDER BY created_at), при инкрементальном run dbt видит только новые строки, нумерация не учитывает всю историю. Решение: материализовать как table или специальная логика с lookup.
2. ORDER BY без LIMIT. Бесполезно в incremental — порядок не сохраняется при INSERT.
3. DISTINCT на исторических данных. Дедуп не работает между уже залитыми и новыми строками. Используй unique_key для дедупа.
4. Self-JOIN на полной истории. Аналогично окнам — JOIN происходит только на новых строках.
Если модель требует “посмотреть на всю историю” в одном запросе — она не подходит для incremental в чистом виде. Можно реализовать через lookup back window (читать историю + дельту), но это уже advanced.
Что в скомпилированном SQL
Сравним два варианта.
Первый run (target/run при первом запуске):
CREATE TABLE jaffle_shop.main.fact_orders AS (
SELECT *
FROM jaffle_shop.main.raw_orders
);
Никакого WHERE — потому что {'{% if is_incremental() %}'} оказался False, блок выпал.
Второй run (target/run после первого):
-- Стратегия по умолчанию для DuckDB — delete+insert; ниже упрощённо
DELETE FROM jaffle_shop.main.fact_orders
WHERE order_id IN (
SELECT order_id FROM (
SELECT *
FROM jaffle_shop.main.raw_orders
WHERE updated_at > (SELECT MAX(updated_at) FROM jaffle_shop.main.fact_orders)
)
);
INSERT INTO jaffle_shop.main.fact_orders (
SELECT *
FROM jaffle_shop.main.raw_orders
WHERE updated_at > (SELECT MAX(updated_at) FROM jaffle_shop.main.fact_orders)
);
Видишь WHERE с фильтром — это и есть рендер {'{% if is_incremental() %}'} блока. Конкретная стратегия (delete+insert vs merge) — в следующем уроке.
Команды для отладки
Посмотреть, сколько строк обработала incremental-модель:
dbt run --select fact_orders
Output:
14:23:01 1 of 1 START sql incremental model main.fact_orders ..... [RUN]
14:23:02 1 of 1 OK created sql incremental model main.fact_orders [INSERT 1245 in 0.45s]
“INSERT 1245” — это число новых строк. Сравни с “100M” при первом run — разница порядков. Если показывает INSERT 0 — нет новых строк, ничего не сделалось.
Чтобы пересчитать всё с нуля:
dbt run --select fact_orders --full-refresh
Output будет похож на первый run.
Попробуй сам
В своём проекте создай models/marts/fact_orders.sql:
{{ config(
materialized='incremental',
unique_key='order_id'
) }}
SELECT
order_id,
customer_id,
amount,
created_at,
updated_at
FROM {{ source('jaffle_shop', 'raw_orders') }}
{% if is_incremental() %}
WHERE updated_at > (SELECT MAX(updated_at) FROM {{ this }})
{% endif %}
Запусти первый раз:
dbt run --select fact_orders
Должно появиться “INSERT N”, где N = число всех orders.
Запусти ещё раз без изменений в raw_orders:
dbt run --select fact_orders
Должно быть “INSERT 0” — ничего нового нет.
Добавь в raw_orders новую строку (например через INSERT INTO raw_orders VALUES (...) напрямую в DuckDB) и запусти dbt run снова. Увидишь “INSERT 1” — обработана только новая строка.
Затем:
dbt run --select fact_orders --full-refresh
Это будет полный пересчёт — обработаются все строки заново.
Что мы поняли
Incremental materialization обрабатывает только новые/изменённые строки, не пересчитывая всю таблицу. Ключевая Jinja-конструкция — {'{% if is_incremental() %}'}, истинная только при инкрементальном run. --full-refresh пересоздаёт таблицу с нуля — для разработки и восстановления. on_schema_change контролирует поведение при изменении схемы модели.
В следующем уроке углубимся в unique_key — параметр, который определяет, как dbt решает, что обновить, а что добавить.