dbt_valid_to_current и snapshot_meta_column_names
В этом уроке — две конфигурации snapshot из dbt 1.9+, которые делают snapshot дружественнее к downstream: dbt_valid_to_current (избавление от NULL в активных строках) и snapshot_meta_column_names (кастомные имена SCD2-колонок).
Если первая часть модуля была про точность истории, эта — про эргономику для тех, кто потом эту историю использует: BI-аналитиков, ML-инженеров, авторов dashboard.
Проблема NULL в dbt_valid_to
По умолчанию активная (текущая) строка snapshot имеет dbt_valid_to = NULL. Это формально корректно — конец интервала не определён, snapshot SCD2 ещё не зафиксировал следующее изменение.
Но downstream-запросы должны это учитывать. Типичный JOIN orders с customers_snapshot:
-- BAD: NULL ломает BETWEEN
SELECT o.*, s.tier
FROM orders o
LEFT JOIN customers_snapshot s
ON o.customer_id = s.customer_id
AND o.order_date BETWEEN s.dbt_valid_from AND s.dbt_valid_to;
order_date BETWEEN dbt_valid_from AND NULL — это >= dbt_valid_from AND <= NULL. Сравнение с NULL -> NULL -> не TRUE -> строка исключается. Активные клиенты теряются из результата.
Решения трёх уровней:
- COALESCE везде в downstream. Каждый JOIN на snapshot должен заворачивать
dbt_valid_toвCOALESCE(valid_to, '9999-12-31'). Один пропуск = бага в проде. - VIEW над snapshot с готовым COALESCE. Создать
dim_customers_viewкоторый делает CHECK / COALESCE автоматически. dbt_valid_to_currentв snapshot config. Самое чистое. Active row сразу записывается с sentinel-датой, NULL не появляется.
Конфиг dbt_valid_to_current
snapshots:
- name: customers_snapshot
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_valid_to_current принимает SQL expression, который вычислит sentinel-значение в момент INSERT. Чаще всего — конкретная далёкая дата:
| Значение | Тип | Pros | Cons |
|---|---|---|---|
"to_date('9999-12-31')" | DATE | Очевидное «бесконечно далёкое будущее». Прост в BI. | Только дата, без времени. |
"to_timestamp('9999-12-31 23:59:59')" | TIMESTAMP | Совместим с timestamp-колонками. | Тяжелее для глаз в raw query. |
"'2999-12-31'::timestamp" | TIMESTAMP | Менее экстремальная sentinel — некоторые BI глупят с 9999. | Слабее «бесконечно». |
"'infinity'::timestamp" | TIMESTAMP (Postgres only) | Honestly infinite. | Не работает на Snowflake / BigQuery / DuckDB. |
Стандартный выбор в 2026: to_date('9999-12-31') для DATE-колонок, to_timestamp('9999-12-31 23:59:59') для TIMESTAMP. Поведение в DuckDB / Postgres / Snowflake / BigQuery согласовано.
Тип dbt_valid_to_current должен совпадать с типом dbt_valid_to колонки snapshot (DATE vs TIMESTAMP). Mixed типы вызовут implicit cast при INSERT и могут потерять precision. Стандартизируйтесь на одном.
Поведение dbt при включении dbt_valid_to_current
Без конфига:
- Новая активная строка:
INSERT (..., dbt_valid_to) VALUES (..., NULL). - При закрытии:
UPDATE SET dbt_valid_to = source.updated_at WHERE dbt_valid_to IS NULL.
С конфигом dbt_valid_to_current: "to_date('9999-12-31')":
- Новая активная строка:
INSERT (..., dbt_valid_to) VALUES (..., to_date('9999-12-31')). - При закрытии:
UPDATE SET dbt_valid_to = source.updated_at WHERE dbt_valid_to = to_date('9999-12-31').
То есть dbt подменяет NULL на sentinel-значение и в проверке «активная ли строка».
Миграция существующего snapshot
Если snapshot уже год работает с dbt_valid_to = NULL для активных, и вы хотите перейти на dbt_valid_to_current — нужна одноразовая миграция.
Шаг 1. Добавить конфиг в YAML. Шаг 2. Сделать UPDATE на старые активные строки:
-- Один раз, вручную / через макрос:
UPDATE snapshots.customers_snapshot
SET dbt_valid_to = '9999-12-31'::date
WHERE dbt_valid_to IS NULL;
Шаг 3. Проверить тестами:
columns:
- name: dbt_valid_to
data_tests:
- not_null
- dbt_utils.expression_is_true:
expression: "dbt_valid_to = to_date('9999-12-31') OR dbt_valid_to < current_date"
Шаг 4. Запустить dbt snapshot. Новые INSERT уже с sentinel — old & new должны быть однородными.
Это безболезненная миграция: ни одна строка не теряется, downstream-запросы можно постепенно переписать.
snapshot_meta_column_names
Data Vault: HASH_DIFF — откуда берётся этот паттернdbt по умолчанию называет meta-колонки snapshot так: dbt_scd_id, dbt_updated_at, dbt_valid_from, dbt_valid_to. Если переезжаете с другого SCD2-стека (Data Vault, ETL tool, hand-rolled snapshot), хочется сохранить старые имена.
dbt 1.9+ позволяет кастомизировать через snapshot_meta_column_names:
snapshots:
- name: customers_snapshot
relation: source('app', 'customers')
config:
schema: snapshots
unique_key: customer_id
strategy: timestamp
updated_at: updated_at
snapshot_meta_column_names:
dbt_valid_from: valid_from_ts
dbt_valid_to: valid_to_ts
dbt_scd_id: hash_diff
dbt_updated_at: source_updated_at
dbt_is_deleted: is_deleted_flag
Теперь snapshot создаёт колонки valid_from_ts, valid_to_ts, hash_diff, source_updated_at, is_deleted_flag — без префикса dbt_.
Это полезно когда:
- Миграция legacy SCD2. Старая команда писала snapshot руками, downstream привык к именам. Переход на dbt не должен ломать BI-отчёты.
- Соответствие naming convention. Корпоративный стандарт требует
_tsсуффиксы или camelCase. snapshot вписывается в convention. - Data Vault интеграция. Колонки соответствуют HASH_DIFF, LOAD_DTS, LOAD_END_DTS терминологии Data Vault.
Кастомные имена применяются только к новым snapshot. Существующие snapshot нужно либо мигрировать вручную (ALTER TABLE RENAME COLUMN), либо запустить --full-refresh (потеря истории).
Влияние на тесты
Стандартные тесты ссылаются на колонки по имени:
columns:
- name: dbt_scd_id
data_tests:
- unique
- not_null
При кастомных именах — обновите references:
columns:
- name: hash_diff
data_tests:
- unique
- not_null
- name: valid_from_ts
data_tests:
- not_null
Также macro dbt_utils.snapshot_active_records и подобные могут принимать имена аргументами. Проверьте, что ваш пакет поддерживает кастомные имена.
Влияние на BI и data exploration
Sentinel-дата 9999-12-31 визуально странна. BI-инструменты (Tableau, Power BI, Looker) по-разному её отображают:
- Tableau показывает как «12/31/9999» — обычно понимается аналитиками.
- Power BI иногда конвертит в
#####(overflow в date pickers). Решение — фильтровать sentinel в BI semantic layer. - Looker работает прозрачно.
- Custom dashboards (Superset, Metabase) — обычно ок, но проверьте edge cases.
Полезный паттерн — экспортировать через mart-модель, где sentinel заменяется на NULL или is_current_flag:
-- models/marts/dim_customers_current.sql
SELECT
customer_id,
name,
tier,
email,
valid_from_ts,
CASE
WHEN valid_to_ts = to_date('9999-12-31') THEN NULL
ELSE valid_to_ts
END AS valid_to_ts,
(valid_to_ts = to_date('9999-12-31')) AS is_current
FROM {{ ref('customers_snapshot') }}
Тогда BI потребляет mart, а не raw snapshot — и видит знакомый NULL / flag.
Edge case: dbt_valid_to_current при invalidate / new_record
Когда hard_deletes: invalidate, dbt при удалении ставит dbt_valid_to = run_started_at. Это не sentinel — это реальное время удаления. Sentinel остаётся только у живых активных строк.
Когда hard_deletes: new_record, sentinel-строка с dbt_is_deleted = TRUE тоже имеет dbt_valid_to = sentinel (потому что это «новая активная» запись со специальным флагом).
-- После последовательности: customer_id=42 был активным, потом удалён
SELECT customer_id, dbt_valid_from, dbt_valid_to, dbt_is_deleted
FROM customers_snapshot
WHERE customer_id = 42
ORDER BY dbt_valid_from;
-- 42 | 2026-01-15 | 2026-05-19 (run_started_at) | FALSE ← закрытая обычная
-- 42 | 2026-05-19 | 9999-12-31 (sentinel) | TRUE ← sentinel «удалён»
Downstream фильтр «активные сейчас» учитывает оба:
WHERE dbt_valid_to = to_date('9999-12-31')
AND COALESCE(dbt_is_deleted, FALSE) = FALSE
Полный production пример с sentinel + кастомные имена
# snapshots/customers_snapshot.yml
snapshots:
- name: customers_snapshot
description: |
SCD2-история customers. Sentinel-дата для активных. Имена колонок — Data Vault convention.
Используется как dimension в financial reporting (compliance).
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')"
snapshot_meta_column_names:
dbt_valid_from: load_dts
dbt_valid_to: load_end_dts
dbt_scd_id: hash_diff
dbt_updated_at: source_modified_at
tags: [snapshot, daily, dimension, compliance]
columns:
- name: customer_id
data_tests:
- not_null
- name: hash_diff
data_tests:
- unique
- not_null
- name: load_dts
data_tests:
- not_null
- name: load_end_dts
data_tests:
- not_null
- dbt_utils.expression_is_true:
expression: "load_end_dts = to_date('9999-12-31') OR load_end_dts >= load_dts"
Попробуй сам
В labs:
- Создайте snapshot с
dbt_valid_to_currentнаcustomers. Запуститеdbt snapshot. Проверьте, что активные строки имеют sentinel-дату вместо NULL. - Напишите JOIN orders с snapshot без COALESCE. Проверьте, что результат корректен (живые клиенты не теряются).
- Уберите
dbt_valid_to_currentиз конфига. Сделайтеdbt snapshot --full-refresh. Снова запустите тот же JOIN — теперь активные клиенты исчезли (NULL в BETWEEN). Восстановите конфиг и заполните UPDATE для существующих NULL. - Попробуйте
snapshot_meta_column_namesс кастомными именами. Сделайтеdbt snapshot --full-refresh(для существующего без full-refresh имена меняются только в новом snapshot). Проверьте, что в warehouse колонки теперь с новыми именами. - Сделайте mart-модель
dim_customers_currentкоторая переводит sentinel обратно в NULL для BI.
Это упражнение демонстрирует, как small config-changes делают snapshot эргономичнее для всех downstream-consumer’ов.
Ключевые выводы
- NULL в
dbt_valid_toломает BETWEEN в downstream JOIN. Решение: COALESCE везде, view-обёртка, илиdbt_valid_to_currentconfig. dbt_valid_to_current: "to_date('9999-12-31')"— sentinel-дата вместо NULL. Чище, меньше bug surface.- Тип sentinel должен совпадать с типом
dbt_valid_toколонки (DATE vs TIMESTAMP). - Миграция существующего snapshot на sentinel — одноразовый UPDATE без потери истории.
snapshot_meta_column_namesпозволяет переименоватьdbt_scd_id/dbt_valid_from/ etc — полезно для legacy миграций и Data Vault convention.- Sentinel-дата может смущать BI-инструменты. Рекомендуется mart-обёртка, которая возвращает NULL и
is_currentflag для downstream. hard_deletes: invalidateиnew_recordкорректно работают со sentinel.