hard_deletes: ignore / invalidate / new_record
dbt snapshot по умолчанию не замечает удалений в source. Если строка пропала — её последняя версия остаётся «активной» с устаревшими данными. Это default hard_deletes: ignore.
В dbt 1.9+ появилась нормальная альтернатива: конфиг hard_deletes с тремя значениями. Этот урок — как они работают, как мигрировать с legacy invalidate_hard_deletes, и что делать на DuckDB, где hard_deletes не реализован.
Три опции конфига
snapshots:
- name: customers_snapshot
relation: source('app', 'customers')
config:
schema: snapshots
unique_key: customer_id
strategy: timestamp
updated_at: updated_at
hard_deletes: invalidate # или 'ignore' (default) / 'new_record'
| Значение | Поведение | Когда выбирать |
|---|---|---|
ignore (default) | Пропавшая строка остаётся активной с устаревшими данными. dbt_valid_to не закрывается. | Source гарантирует append-only / soft-delete. Удалений не бывает. |
invalidate | Пропавшая строка закрывается: dbt_valid_to = run_started_at. Активной версии больше нет. | Source действительно удаляет строки (или soft-deleted строки нужно исключать из active). |
new_record | Пропавшая строка закрывается + создаётся новая «sentinel»-версия с dbt_is_deleted = TRUE. | Нужна явная пометка «удалено», а не просто отсутствие активной версии. Audit / compliance. |
Что меняется в схеме snapshot при new_record
Если выбрали hard_deletes: new_record, к snapshot добавляется колонка:
| Колонка | Тип | Значение |
|---|---|---|
dbt_is_deleted | BOOLEAN / TEXT | TRUE для «delete-сигнала», FALSE (или NULL) для обычных версий. |
Downstream запросы могут различать:
-- Активные клиенты сейчас
SELECT *
FROM customers_snapshot
WHERE dbt_valid_to IS NULL
AND dbt_is_deleted = FALSE; -- исключаем явно удалённых
-- Когда был удалён клиент 42?
SELECT dbt_valid_from AS deleted_at
FROM customers_snapshot
WHERE customer_id = 42
AND dbt_is_deleted = TRUE;
С invalidate той же логики добиться сложнее: «нет активной версии» может означать как удаление, так и баг загрузки. С new_record явная пометка — proof of deletion.
Когда выбирать какую опцию
GDPR и право на забвение — почему hard_deletes критичны для complianceignore (default):
- Source action гарантирует, что строки никогда не удаляются (только soft-delete с флагом).
- Snapshot для immutable таблиц.
- Не критично знать «когда удалили». Например, в небольшом проекте c append-only данными.
invalidate:
- Source может физически удалять строки.
- Downstream-логика просто фильтрует
WHERE dbt_valid_to IS NULL. - Достаточно знать, что строки «больше нет», не нужно знать «когда удалена».
new_record:
- Compliance / regulatory требования. Нужен audit trail удалений.
- Аналитика «когда / сколько удалений в день».
- BI-отчёты, где нужно явно показывать удалённых клиентов с пометкой.
В production я бы предложил new_record как default для customer-related snapshots (compliance), invalidate для technical reference dimensions, ignore — только если source гарантирован append-only.
Миграция с legacy invalidate_hard_deletes
До dbt 1.9 был только один булевый флаг: invalidate_hard_deletes: true / false. Это эквивалентно hard_deletes: invalidate / ignore соответственно.
# Старый синтаксис (deprecated)
config:
invalidate_hard_deletes: true
# Новый синтаксис 1.9+
config:
hard_deletes: invalidate
Что нужно сделать при миграции с 1.8 -> 1.9+:
- Заменить ключ в YAML:
invalidate_hard_deletes: true->hard_deletes: invalidate.invalidate_hard_deletes: false->hard_deletes: ignore(или просто удалить — это default). - Проверить тесты. dbt 1.9 даёт
WARNна старый ключ, в 1.10+ может статьERROR. - Опционально мигрировать на
new_recordдля audit-critical snapshots.
# Если в проекте много snapshot — поиск-замена
grep -rn "invalidate_hard_deletes" snapshots/
После замены — dbt snapshot --select snapshot:* должен пройти без warnings.
В legacy .sql-snapshot с Jinja-блоком {% snapshot %} тоже работает новый ключ:
{{
config(
strategy='timestamp',
hard_deletes='invalidate'
)
}}Алгоритм dbt при разных hard_deletes
Рассмотрим в деталях, какой SQL генерирует dbt.
ignore (default):
-- 1. Закрываем changed versions (что есть в source и в snapshot, но изменилось)
UPDATE customers_snapshot
SET dbt_valid_to = source.updated_at
WHERE customer_id IN (changed_ids)
AND dbt_valid_to IS NULL;
-- 2. INSERT new versions
INSERT INTO customers_snapshot (...)
SELECT ... FROM source
WHERE customer_id IN (new_ids + changed_ids);
-- DELETED IDs не трогаются — остаются активными.
invalidate:
-- Шаги 1-2 как в ignore.
-- 3. Закрываем deleted: те, что есть в snapshot активные, но нет в source
UPDATE customers_snapshot
SET dbt_valid_to = '{{ run_started_at }}'
WHERE dbt_valid_to IS NULL
AND customer_id NOT IN (SELECT customer_id FROM source);
new_record:
-- Шаги 1-3 как в invalidate.
-- 4. Вставляем sentinel-строки для deleted
INSERT INTO customers_snapshot (...)
SELECT
snap.customer_id,
snap.name,
snap.tier,
...
TRUE AS dbt_is_deleted,
'{{ run_started_at }}' AS dbt_valid_from,
NULL AS dbt_valid_to,
md5(snap.customer_id || '{{ run_started_at }}') AS dbt_scd_id
FROM customers_snapshot snap
WHERE snap.dbt_valid_to = '{{ run_started_at }}' -- только что закрытые
AND snap.customer_id NOT IN (SELECT customer_id FROM source);
Это упрощённая схема. Реальный SQL зависит от warehouse и адаптера.
DuckDB: hard_deletes не работает
На dbt-duckdb 1.10.x состояние 2026 года hard_deletes не реализован. Это известное ограничение адаптера.
Что происходит, если попытаться:
config:
hard_deletes: invalidate # на DuckDB
Поведение зависит от версии адаптера:
- В некоторых версиях — silent ignore. Snapshot работает как
hard_deletes: ignore, но без warning. - В новых — compile error:
Adapter does not support hard_deletes config.
Это означает: на DuckDB нет нативного способа отслеживать удаления через snapshot.
Workaround 1: Soft-delete pattern (рекомендуется)
В source держите колонку is_deleted boolean. При логическом удалении выполняйте UPDATE, а не DELETE:
-- В источнике
UPDATE app.customers SET is_deleted = TRUE WHERE customer_id = 42;
Snapshot отслеживает is_deleted как обычную SCD2-колонку:
С timestamp-стратегией (если updated_at обновился при is_deleted=TRUE):
config:
strategy: timestamp
updated_at: updated_at
# hard_deletes не нужен
Когда is_deleted переходит false -> true, дополнительно обновлённый updated_at приведёт к новой SCD2-версии. Downstream: WHERE is_deleted = FALSE для активных.
С check-стратегией:
config:
strategy: check
check_cols:
- tier
- is_deleted # обязательно в check_cols
Любой переход is_deleted создаёт новую версию.
Workaround 2: post-hook на snapshot
Если нельзя добавить колонку в source — добавьте post-hook, который вручную закрывает «пропавшие» строки:
config:
strategy: timestamp
updated_at: updated_at
post-hook: |
UPDATE {{ this }}
SET dbt_valid_to = '{{ run_started_at }}'
WHERE dbt_valid_to IS NULL
AND customer_id NOT IN (
SELECT customer_id FROM {{ source('app', 'customers') }}
)
Это эквивалент invalidate вручную. Минусы:
- Менее декларативно — нужно помнить про post-hook.
- Не работает для
new_record(нужно более сложный INSERT). - post-hook выполняется после snapshot run, в той же транзакции — должно быть корректно атомарно.
Workaround 3: переезд на Postgres / Snowflake
Если бизнесу критично знать «кто удалён и когда» — DuckDB не подходит. Это знак, что snapshot пора переносить в полноценное warehouse, где hard_deletes нативно.
Edge case: false positives при invalidate
Snapshot прогоняется с invalidate. Source — это VIEW, которая JOIN-ит несколько таблиц. Если одна из таблиц временно недоступна / часть данных не подгрузилась — VIEW вернёт меньше строк, и snapshot закроет все «пропавшие» как deleted.
Через час источник восстановился — VIEW снова показывает полные данные. Snapshot создаёт новые версии для всех «вернувшихся» — теперь у каждого клиента две закрытые версии и одна активная с искусственными разрывами.
Mitigation:
- Source freshness check перед snapshot:
dbt source freshness && dbt snapshot
Если freshness fail — не запускать snapshot.
- Row count sanity check:
-- В CI / pre-snapshot job
{% set source_count = run_query("SELECT COUNT(*) FROM " ~ source('app', 'customers')) %}
{% set snapshot_count = run_query("SELECT COUNT(*) FROM " ~ ref('customers_snapshot') ~ " WHERE dbt_valid_to IS NULL") %}
{% if source_count[0][0] < snapshot_count[0][0] * 0.5 %}
{{ exceptions.raise_compiler_error("Source row count dropped > 50%, abort snapshot") }}
{% endif %}
- Не делать snapshot на VIEW — только на материализованных таблицах source.
invalidate чувствителен к качеству source. Любой пропуск данных в source приведёт к ложному закрытию SCD2-версий. Защита — source freshness + row count check.
Полный production пример с new_record + soft-delete
# snapshots/customers_snapshot.yml
snapshots:
- name: customers_snapshot
description: |
SCD2-история customers с audit deletion log.
На DuckDB: workaround через soft-delete is_deleted колонку.
Расписание: ежедневно в 02:00 UTC.
relation: source('app', 'customers')
config:
schema: snapshots
unique_key: customer_id
strategy: timestamp
updated_at: updated_at
dbt_valid_to_current: "to_date('9999-12-31')"
tags: [snapshot, daily, dimension, gdpr]
columns:
- name: customer_id
data_tests:
- not_null
- name: is_deleted
description: "Soft delete flag — заменяет hard_deletes для DuckDB"
- name: dbt_scd_id
data_tests:
- unique
- not_null
На Snowflake / Postgres / BigQuery — тот же snapshot, но hard_deletes: new_record нативно:
# snapshots/customers_snapshot.yml (Snowflake / Postgres)
config:
schema: snapshots
unique_key: customer_id
strategy: timestamp
updated_at: updated_at
hard_deletes: new_record
dbt_valid_to_current: "TO_DATE('9999-12-31')"
Хорошая практика: вынести стратегию hard_deletes в vars или dbt_project.yml, чтобы свитчить между средами:
# dbt_project.yml
snapshots:
my_project:
+hard_deletes: "{{ var('hard_deletes_mode', 'ignore') }}"
dbt snapshot --vars '{hard_deletes_mode: invalidate}'
Попробуй сам
В labs-репозитории:
- Создайте
customers_snapshotсhard_deletes: ignore(или просто без конфига). Удалите одного клиента из source. Запуститеdbt snapshot. Проверьте — его строка осталась активной. - Попробуйте поставить
hard_deletes: invalidateна DuckDB. Что произойдёт? Если падает — попробуйтеnew_record. Зафиксируйте версию dbt-duckdb и сообщение об ошибке. - Реализуйте soft-delete workaround. Добавьте колонку
is_deletedв источник, обновите её для удалённого клиента. Запустите snapshot — должна появиться новая версия сis_deleted = TRUE. - Реализуйте post-hook workaround. Без
is_deleted— добавьте post-hook, который закрывает «пропавшие» customer_id. Проверьте, что dbt_valid_to ставится корректно.
Этот эксперимент важен, потому что на DuckDB вы реально упрётесь в ограничение, и нужно знать workarounds на практике.
Ключевые выводы
- dbt 1.9+ ввёл
hard_deletes: ignore / invalidate / new_record. Раньше — булевыйinvalidate_hard_deletes. ignore(default) пропускает удаления;invalidateзакрывает SCD2-версии;new_recordдополнительно добавляет sentinel-строку сdbt_is_deleted = TRUE.- На DuckDB
hard_deletesне реализован (адаптер 1.10.x, 2026). Workarounds: soft-delete колонкаis_deletedв source, post-hook, или переезд на Postgres / Snowflake. - Миграция с
invalidate_hard_deletes: true->hard_deletes: invalidate— простая замена ключа в YAML. invalidateчувствителен к качеству source. Любой пропуск данных создаст ложные «удаления». Защита — source freshness + row count check.- Для compliance / audit предпочитайте
new_record— это даёт proof of deletion с timestamp.