Snapshot стратегии: timestamp vs check
В предыдущем уроке вы поняли зачем нужны snapshots. Этот урок — как именно dbt определяет, что строка изменилась. Есть две стратегии, и выбор между ними — главное архитектурное решение при настройке snapshot.
Также этот урок — про новый YAML-синтаксис из dbt 1.9+, который пришёл на смену старому .sql файлу с Jinja-блоком.
Две стратегии — timestamp и check
dbt предлагает два способа определить, что строка в source-таблице изменилась:
Правило большого пальца:
- Есть надёжный
updated_atв source — берите timestamp. - Нет — берите check и явно перечисляйте колонки, которые хотите отслеживать.
Не используйте check со всеми колонками подряд — это медленно и шумно. Если у вас есть колонка last_login_at, которая меняется при каждом заходе пользователя, — она забьёт snapshot тысячами «изменений».
YAML-синтаксис 1.9+
Начиная с dbt 1.9, snapshots декларируются в YAML. Раньше использовался .sql файл с Jinja:
{# Старый синтаксис dbt < 1.9 — deprecated path #}
{% snapshot customers_snapshot %}
{{
config(
target_schema='snapshots',
unique_key='customer_id',
strategy='timestamp',
updated_at='updated_at'
)
}}
SELECT * FROM {{ source('app', 'customers') }}
{% endsnapshot %}
С 1.9 — это YAML в snapshots/:
# snapshots/customers_snapshot.yml — НОВЫЙ синтаксис 1.9+
snapshots:
- name: customers_snapshot
relation: source('app', 'customers')
config:
schema: snapshots
unique_key: customer_id
strategy: timestamp
updated_at: updated_at
Если нужен более сложный SQL (фильтры, JOIN), всё ещё пишите .sql файл, но Jinja-блок {% snapshot %} больше не нужен:
-- snapshots/customers_snapshot.sql — для сложной SQL логики
{{
config(
target_schema='snapshots',
unique_key='customer_id',
strategy='timestamp',
updated_at='updated_at'
)
}}
SELECT
customer_id,
name,
address,
tier,
updated_at
FROM {{ source('app', 'customers') }}
WHERE deleted = false
В этом курсе используем YAML вариант как рекомендуемый.
В dbt 1.10 старый {% snapshot %} блок ещё работает с warning. В будущих версиях будет удалён. Все новые snapshots пишите в YAML.
Полный YAML snapshot — timestamp
# snapshots/customers_snapshot.yml
snapshots:
- name: customers_snapshot
description: "SCD2-история таблицы customers с момента запуска dbt"
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')"
Разберём каждое поле:
| Поле | Значение | Зачем |
|---|---|---|
name | customers_snapshot | Имя snapshot в DAG. Будет таблица с этим именем в warehouse. |
relation | source('app', 'customers') | Откуда брать данные. Может быть source(...), ref(...), или прямой SQL через .sql. |
schema | snapshots | Схема целевой таблицы. Дефолт = target schema. |
unique_key | customer_id | Идентификатор записи. Обязателен. Может быть строкой или списком. |
strategy | timestamp | Стратегия определения изменений. |
updated_at | updated_at | Колонка timestamp в source. Используется для сравнения. |
dbt_valid_to_current | "to_date('9999-12-31')" | Что писать в dbt_valid_to для активных строк. По умолчанию NULL. |
dbt_valid_to_current — полезная опция. Если оставить NULL — нужно делать COALESCE(dbt_valid_to, '9999-12-31') во всех downstream-запросах. Если поставить sentinel-дату — JOIN-ы упрощаются.
Полный YAML snapshot — check
# snapshots/products_snapshot.yml
snapshots:
- name: products_snapshot
description: "История изменений products: цена и категория"
relation: source('app', 'products')
config:
schema: snapshots
unique_key: product_id
strategy: check
check_cols:
- price
- category
- is_active
Ключевое отличие — check_cols. dbt будет отслеживать только эти три колонки. Изменение name (если оно случится) не создаст новую SCD2-версию.
Можно также указать check_cols: 'all' — отслеживать все колонки. Но это создаёт проблемы:
- Шумно. Малейшее изменение в любой колонке создаёт новую версию.
- Медленнее. Большое сравнение на каждый run.
- Нестабильно. Если source добавит/удалит колонку — структура snapshot нарушится.
check_cols: 'all' — антипаттерн, не делайте так.
Стратегия check не отслеживает изменения в колонках, которые не в check_cols. Если price поменялся и вы забыли его добавить — изменение пропустится. Перечисляйте все business-critical колонки.
unique_key — composite key
Если строка идентифицируется не одной колонкой, а комбинацией (например, customer_id + product_id в таблице subscriptions) — unique_key может быть списком:
snapshots:
- name: subscriptions_snapshot
relation: source('app', 'subscriptions')
config:
unique_key:
- customer_id
- product_id
strategy: timestamp
updated_at: updated_at
dbt сгенерирует MERGE с условием на оба поля. Если в source есть две строки с одинаковым customer_id, но разным product_id — snapshot их различит.
unique_key должен быть stable и non-null. Если customer_id когда-то NULL — snapshot создаст «зависшую» строку без идентификации, и каждый run будет делать новый INSERT (думая что это новая запись). Всегда добавляйте not_null тест на колонки unique_key источника.
Дополнительные колонки в snapshot
После выполнения snapshot, помимо source-колонок, в таблице будут:
| Колонка | Значение |
|---|---|
dbt_scd_id | Хеш SHA256 от unique_key + updated_at (или check_cols). Уникальный идентификатор версии записи. |
dbt_updated_at | Timestamp создания этой версии в snapshot. Для timestamp = source.updated_at. Для check = время snapshot. |
dbt_valid_from | С какого момента эта версия действительна. = dbt_updated_at. |
dbt_valid_to | До какого момента эта версия действительна. NULL (или dbt_valid_to_current) для активной строки. |
В check-стратегии есть нюанс: dbt_updated_at = время выполнения snapshot, а не реальное время изменения данных. Если check запустился через 24 часа после изменения — dbt_valid_from будет на 24 часа позже фактического изменения.
Это компромисс: check не знает, когда именно строка изменилась, только что изменилась.
Полный пример use-case: jaffle_shop
В Jaffle Shop у нас есть source-таблица app.customers:
-- Что есть в source
SELECT * FROM app.customers;
| customer_id | name | tier | updated_at | |
|---|---|---|---|---|
| 1 | Alice | standard | [email protected] | 2026-01-15 10:00:00 |
| 2 | Bob | premium | [email protected] | 2026-02-20 11:00:00 |
Создаём snapshot:
# snapshots/_snapshots.yml
snapshots:
- name: customers_snapshot
description: "История изменений customers — для атрибуции продаж по tier"
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')"
Запускаем первый раз:
dbt snapshot
В snapshots.customers_snapshot появятся две строки (по одной на customer) с dbt_valid_to = 9999-12-31.
Через неделю Алиса апгрейднулась до premium tier. В source:
UPDATE app.customers
SET tier = 'premium', updated_at = '2026-01-22 14:00:00'
WHERE customer_id = 1;
Запускаем snapshot снова:
dbt snapshot
В snapshot теперь:
| customer_id | name | tier | updated_at | dbt_valid_from | dbt_valid_to |
|---|---|---|---|---|---|
| 1 | Alice | standard | 2026-01-15 10:00:00 | 2026-01-15 10:00:00 | 2026-01-22 14:00:00 |
| 1 | Alice | premium | 2026-01-22 14:00:00 | 2026-01-22 14:00:00 | 9999-12-31 |
| 2 | Bob | premium | 2026-02-20 11:00:00 | 2026-02-20 11:00:00 | 9999-12-31 |
Теперь модель customers_orders может корректно атрибутировать каждую транзакцию к tier на момент сделки:
-- models/marts/orders_with_tier.sql
SELECT
o.order_id,
o.customer_id,
o.order_date,
o.amount,
s.tier AS tier_at_order_time
FROM {{ ref('stg_jaffle__orders') }} o
LEFT JOIN {{ ref('customers_snapshot') }} s
ON o.customer_id = s.customer_id
AND o.order_date >= s.dbt_valid_from
AND o.order_date < s.dbt_valid_to
Если Алиса сделала заказ 2026-01-18 (до апгрейда) — tier_at_order_time = 'standard'. Если 2026-01-25 (после) — tier_at_order_time = 'premium'. Это и есть атрибуция на момент.
invalidate_hard_deletes — что делать, если строка пропала
В dbt 1.9+ есть конфиг hard_deletes с тремя значениями:
| Значение | Поведение |
|---|---|
ignore (default) | Если строка пропала из source — игнорируем. Snapshot оставляет последнюю версию как «активную». Дефолт. |
invalidate | Если строка пропала — закрываем версию (dbt_valid_to = now()). Активной версии больше нет. |
new_record | Если строка пропала — добавляем новую версию с флагом dbt_is_deleted = TRUE. Старая версия закрывается. |
snapshots:
- name: customers_snapshot
relation: source('app', 'customers')
config:
schema: snapshots
unique_key: customer_id
strategy: timestamp
updated_at: updated_at
hard_deletes: invalidate # 1.9+ feature
hard_deletes поддерживается на Postgres, Snowflake, BigQuery. В DuckDB на момент 2026 это не реализовано. На DuckDB используйте soft-delete паттерн: в source держите колонку is_deleted boolean, и trackайте её через check-стратегию или включите в timestamp-логику.
Запуск и расписание
Snapshot — отдельная команда:
dbt snapshot # запустит все snapshots
dbt snapshot --select customers_snapshot # один конкретный
dbt build --select +my_mart # включит snapshots в DAG до my_mart
В production snapshot обычно прогоняется по расписанию через cron / Airflow / dbt Cloud Jobs:
- Ежедневно — типичная частота для customers, products dimension.
- Каждый час — для быстро меняющихся источников (например, статусы заказов).
- Каждые 15 минут — для realtime-аналитики (редко в junior-проектах).
Чем чаще запускается snapshot, тем точнее история — но больше I/O и storage.
Попробуй сам
В вашем dbt-проекте на DuckDB:
- Создайте source-таблицу
customers_demoс колонкамиcustomer_id,tier,updated_at:
CREATE TABLE main.customers_demo AS
SELECT * FROM (VALUES
(1, 'Alice', 'standard', TIMESTAMP '2026-01-15 10:00:00'),
(2, 'Bob', 'premium', TIMESTAMP '2026-02-20 11:00:00')
) AS t(customer_id, name, tier, updated_at);
- Опишите её как source в
models/_sources.yml:
sources:
- name: app
schema: main
tables:
- name: customers_demo
- Создайте snapshot
snapshots/customers_snapshot.yml:
snapshots:
- name: customers_snapshot
relation: source('app', 'customers_demo')
config:
schema: snapshots
unique_key: customer_id
strategy: timestamp
updated_at: updated_at
- Запустите:
dbt snapshot
dbt show --inline "SELECT * FROM {{ ref('customers_snapshot') }}"
- Измените tier Алисы:
UPDATE main.customers_demo
SET tier = 'premium', updated_at = TIMESTAMP '2026-01-22 14:00:00'
WHERE customer_id = 1;
- Снова
dbt snapshot. В таблице теперь три строки. Старая Алиса сdbt_valid_to, новая Алиса сdbt_valid_to IS NULL, Боб без изменений.
Бонус: переделайте на check-стратегию (check_cols: [tier]). Что меняется в timestamp колонок dbt_valid_from / dbt_valid_to?
Ключевые выводы
- Две стратегии: timestamp (сравнение
updated_at) и check (сравнение значений вcheck_cols). Выбор зависит от наличия надёжногоupdated_atв source. - YAML-синтаксис 1.9+ — новый стандарт. Старый
.sqlс{% snapshot %}блоком — deprecated path. - unique_key обязателен. Может быть строкой или списком. Должен быть not-null и stable.
- Колонки SCD2:
dbt_scd_id,dbt_updated_at,dbt_valid_from,dbt_valid_to. Активные строки имеютdbt_valid_to = NULLилиdbt_valid_to_current. - check_cols: ‘all’ — антипаттерн. Перечисляйте конкретные business-critical колонки.
- hard_deletes конфиг (1.9+) —
ignore/invalidate/new_record. В DuckDB не реализован — используйте soft-delete. - Snapshot запускается отдельной командой
dbt snapshotили включается вdbt build. Расписание — обычно ежедневно через cron / Airflow. - Use case: атрибуция фактов к dimension-данным на момент через JOIN по
order_date BETWEEN dbt_valid_from AND dbt_valid_to.