В прошлом уроке мы поняли базовую механику incremental. Теперь — главный параметр, который определяет корректность инкрементальных обновлений: unique_key.
Если unique_key не задан или задан неправильно, incremental-модель быстро накапливает дубли или теряет обновления существующих строк. Это самая частая ошибка junior’ов в incremental.
Зачем нужен unique_key
Представь сценарий: ты делаешь incremental на orders. Каждая строка orders в raw имеет order_id и updated_at. Orders может обновиться в источнике: например, доставка изменила status. Тогда строка orders с тем же order_id приходит с новым status и новым updated_at.
Без unique_key dbt просто добавит новую строку — в твоей incremental-таблице будут две строки с одним и тем же order_id: старая и новая. Дубли.
С unique_key dbt знает: “если приходит строка с order_id=X, проверь, есть ли уже такой order_id, и если есть — обнови, если нет — добавь”. Это и есть upsert.
Без unique_key — все новые строки идут INSERT'ом, даже если order_id уже существует. С unique_key — dbt различает новые и обновлённые, делает upsert (UPDATE существующих + INSERT новых).
Минимальный пример с unique_key
{{ 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 %}
При инкрементальном run dbt:
- Делает SELECT с WHERE — получает только новые/изменённые строки.
- Для каждой строки проверяет, есть ли уже такой
order_idв таблице. - Если есть — UPDATE (или DELETE+INSERT, в зависимости от стратегии).
- Если нет — INSERT.
Конкретная реализация зависит от incremental_strategy (следующий урок), но смысл unique_key одинаков: “вот колонка, по которой различаешь уникальные строки”.
unique_key может быть несколькими колонками
Иногда уникальность определяется композицией. Например, event_id сам по себе может повторяться, но (event_id, event_timestamp) уникален. Это пишется списком:
{{ config(
materialized='incremental',
unique_key=['event_id', 'event_timestamp']
) }}
dbt будет делать matching по обеим колонкам. Это эквивалент составного primary key.
Также unique_key может быть выражением:
{{ config(
materialized='incremental',
unique_key="coalesce(order_id, '-1')"
) }}
Тут coalesce обрабатывает NULL — все NULL сводятся к одной “псевдо-уникальной” строке. Это далеко не лучший паттерн (про NULL — ниже), но синтаксически возможно.
Что если unique_key не задан
Если unique_key не указать:
{{ config(materialized='incremental') }}
dbt используется стратегию append по умолчанию — каждый run просто INSERT новых строк. Никакой дедупликации, никаких updates. Подходит для append-only таблиц, где данные никогда не обновляются (event logs, page views, immutable events).
Если у тебя данные могут меняться (orders, users) — обязательно укажи unique_key.
Самая частая ошибка: incremental без unique_key на таблице, где данные обновляются. Через неделю замечаешь, что в incremental-таблице 3M строк, а в raw — 1M. Дубли накопились, потому что dbt не понимал, как объединять. Решение: добавить unique_key и сделать —full-refresh.
NULL в unique_key — катастрофа
Главный gotcha: если unique_key колонка содержит NULL, поведение зависит от стратегии и warehouse. На большинстве:
merge— NULL не равен NULL, поэтому каждый NULL попадает как новая строка -> дубли.delete+insert— то же самое, потому что DELETE WHERE col IN (…) не матчит NULL.append— без unique_key всё равно, dubles.
Это значит, что колонка unique_key должна быть NOT NULL. Если в источнике могут быть NULL — отфильтруй их в SELECT:
SELECT *
FROM {{ source('jaffle_shop', 'raw_orders') }}
WHERE order_id IS NOT NULL
Или используй coalesce с уникальным fallback:
SELECT
coalesce(order_id, 'unknown-' || gen_random_uuid()) AS order_id,
...
FROM ...
На junior-курсе достаточно запомнить: NULL в unique_key = баги. Проверь источник, чтобы там было NOT NULL.
Сурогатный ключ для нетривиальной уникальности
Иногда естественного unique_key нет: в raw данные приходят без id, но уникальность определяется композицией нескольких колонок. Стандартный паттерн — surrogate key через хэш:
{{ config(
materialized='incremental',
unique_key='event_key'
) }}
SELECT
{{ dbt_utils.generate_surrogate_key(['session_id', 'event_type', 'event_timestamp']) }} AS event_key,
session_id,
event_type,
event_timestamp,
payload
FROM {{ source('analytics', 'raw_events') }}
{% if is_incremental() %}
WHERE event_timestamp > (SELECT MAX(event_timestamp) FROM {{ this }})
{% endif %}
dbt_utils.generate_surrogate_key(['a', 'b', 'c']) — macro из пакета dbt_utils, создаёт md5-хэш конкатенации колонок. Это даёт стабильный, не-NULL ключ. Используется повсеместно в data engineering.
Surrogate key через хэш — стандартный паттерн в data engineering. Преимущество: стабильный ID, который не меняется при добавлении новых колонок (если используешь только смысловые поля). Минус: не читаемый, не отвечает на вопрос ‘это order_id 5’. Используй когда естественного ключа нет.
Как dbt применяет unique_key (упрощённо)
В зависимости от стратегии (delete+insert по умолчанию для DuckDB):
-- delete+insert: сначала удаляет matching, потом вставляет всё новое
DELETE FROM target
WHERE order_id IN (SELECT order_id FROM new_data);
INSERT INTO target
SELECT * FROM new_data;
Логика: “удали все строки, чьи unique_key есть в новых данных, и потом вставь всё новое”. Эквивалент upsert.
С merge (DuckDB 1.4+):
-- merge: атомарно UPDATE + INSERT в одной операции
MERGE INTO target
USING new_data
ON target.order_id = new_data.order_id
WHEN MATCHED THEN UPDATE SET ...
WHEN NOT MATCHED THEN INSERT ...;
Семантика одинаковая, но merge атомарнее и часто быстрее. Подробнее — в следующем уроке.
Можно ли менять unique_key между runs
Нет, не рекомендуется. Если ты сделал dbt run с unique_key=‘order_id’, а потом сменил на unique_key=‘order_uuid’, dbt при следующем run попытается матчить по новому полю и:
- Если
order_uuidуже был в таблице — будет частичная дедупликация по новому ключу. - Если
order_uuid— новая колонка, добавленная в SQL модели, схема может конфликтовать (см. on_schema_change).
Правильный workflow при смене unique_key:
dbt run --select fact_orders --full-refresh
Это сделает CREATE TABLE с нуля, используя новый unique_key.
Best practices
1. Всегда задавай unique_key, кроме чисто append-only. Event logs без обновлений могут без unique_key. Всё остальное — с ним.
2. unique_key должен быть NOT NULL. Фильтруй NULL в SELECT, или используй surrogate key.
3. Для составных ключей используй surrogate. Лучше один хэш-колонка, чем массив из нескольких. Меньше путаницы.
4. unique_key не обязательно primary key модели. Например, у тебя orders с order_id (PK) и order_uuid (UUID). Можешь использовать любую — главное, чтобы была уникальной и не-NULL.
5. Если unique_key изменяется — full-refresh. Не пытайся “поменять на лету”.
DuckDB-специфика для unique_key
В DuckDB unique_key работает на любой стратегии:
- append (без unique_key) — простой INSERT, всегда supported.
- delete+insert (с unique_key) — supported, стратегия по умолчанию.
- merge (с unique_key) — supported в DuckDB 1.4+. Раньше — error.
- microbatch (event_time, не unique_key) — supported в dbt-core 1.9+, но в dbt-duckdb с ограничением: microbatch с unique_key не поддерживается. Подробнее в уроке 04.
На DuckDB 1.4+ merge — наиболее эффективная стратегия. На более старых версиях DuckDB используй delete+insert.
Команды для проверки
После dbt run проверь количество строк vs ожидаемое:
-- В DuckDB CLI
SELECT COUNT(*), COUNT(DISTINCT order_id) FROM fact_orders;
Если COUNT(*) > COUNT(DISTINCT order_id) — у тебя дубли. Возможные причины: unique_key не задан, есть NULL в unique_key, стратегия append вместо merge.
# Тест на uniqueness через dbt
dbt test --select fact_orders
Если в YAML повешен tests: [unique] на колонку unique_key — он сразу покажет дубли.
Попробуй сам
Создай models/marts/dim_customers_inc.sql:
{{ config(
materialized='incremental',
unique_key='customer_id'
) }}
SELECT
customer_id,
first_name,
last_name,
updated_at
FROM {{ source('jaffle_shop', 'raw_customers') }}
WHERE customer_id IS NOT NULL
{% if is_incremental() %}
AND updated_at > (SELECT MAX(updated_at) FROM {{ this }})
{% endif %}
Запусти первый раз:
dbt run --select dim_customers_inc
Затем измени одну строку в raw_customers — например, обнови имя у customer_id=1:
duckdb jaffle_shop.duckdb -c "UPDATE raw_customers SET first_name='UpdatedName', updated_at=now() WHERE customer_id=1;"
Запусти dbt run снова:
dbt run --select dim_customers_inc
Проверь:
duckdb jaffle_shop.duckdb -c "SELECT * FROM dim_customers_inc WHERE customer_id=1;"
Должна быть одна строка с обновлённым именем — не две. unique_key=‘customer_id’ заставил dbt обновить existing, а не добавить новую.
Теперь убери unique_key и сделай dbt run —full-refresh, потом обнови ту же строку и запусти dbt run. Увидишь, что без unique_key создаются дубли.
Что мы поняли
unique_key — параметр incremental, который определяет колонку (или композицию) уникальности. Без него — append (дубли), с ним — корректный upsert. NULL в unique_key ломает дедупликацию. Для нестандартных случаев используй surrogate key через dbt_utils.generate_surrogate_key. unique_key должен быть стабильным между runs — менять на лету не безопасно, нужен —full-refresh.
В следующем уроке разберём конкретные стратегии: append, delete+insert, merge — и когда какая лучше.
incremental_predicates: оптимизация MERGE