Model contracts: концепт enforced контракта
Когда модель fct_orders используется в 15 dashboards и 3 ML моделях, обещание schema — это критично. Если data engineer переименовал order_amount -> order_total, downstream ломается. Если кто-то накосячил с типом (NUMERIC(12, 2) -> FLOAT64), агрегаты дрейфуют. Если добавил колонку (new_field), некоторые consumers могут начать использовать её, и теперь её нельзя удалить.
В dbt I мы писали YAML schema с descriptions и tests — это документация + проверки данных, но не контракт. Можно изменить SQL модели без изменения YAML — dbt не возразит, новая колонка просто появится в warehouse.
Model contract (dbt 1.5+) — это enforced schema declaration. Когда contract.enforced: true, dbt во время compile/run проверяет, что:
- Все колонки, объявленные в YAML, присутствуют в SQL.
- Типы колонок в SQL соответствуют объявленным.
- Constraints (PK, FK, NOT NULL, CHECK) применены.
- Нет лишних колонок в SQL (которые не в YAML).
Это build-time gate: модель не запустится, если контракт нарушен. Это разница между «надеюсь» и «гарантируется».
Этот урок — про концепт. Следующие — про синтаксис, versions, миграцию, CI.
Контракт vs data tests vs YAML schema
Open Data Contract Standard (ODCS) — formal contract specification SQL constraints: PRIMARY KEY, FOREIGN KEY, CHECK, UNIQUEТри разных уровня enforcement:
Все три комплементарны:
- YAML schema = документация + базовый parse
- Data tests = качество данных (значения)
- Contract = структурная стабильность (форма)
Production-grade модель имеет все три.
Простой пример
Модель без contract:
- name: customer_metrics
description: "..."
columns:
- name: customer_id
data_type: bigint # это decorative — не enforced
description: "..."
- name: revenue
data_type: numeric(12, 2)
-- models/marts/customer_metrics.sql
SELECT
c.customer_id::bigint,
COALESCE(SUM(o.order_total), 0)::float AS revenue, -- FLOAT, не NUMERIC!
c.first_name -- НЕ объявлено в YAML, но добавится
FROM ...
dbt run пройдёт. Таблица customer_metrics создаётся с колонками: customer_id BIGINT, revenue FLOAT, first_name VARCHAR. YAML говорит revenue numeric(12,2), реально — FLOAT. YAML не объявил first_name, но она есть. Schema дрейфовала.
Модель с contract:
- name: customer_metrics
config:
contract:
enforced: true
columns:
- name: customer_id
data_type: bigint
constraints: [{type: not_null}, {type: primary_key}]
- name: revenue
data_type: numeric(12, 2)
constraints: [{type: not_null}]
Тот же SQL -> dbt run падает:
Contract enforcement failure:
- Column 'revenue' has type 'double precision' but YAML declares 'numeric(12, 2)'
- Column 'first_name' present in SQL but not declared in YAML
dbt отказывается материализовать модель. Producer obязан исправить либо SQL (привести в соответствие YAML), либо YAML (отразить изменения). Принудительная синхронизация.
Что гарантирует enforced contract
Чёткое разграничение: contract = schema, tests = data quality. Они комплементарны.
Когда нужен contract
Contract не нужен на каждой модели — overhead в YAML и поддержке. Используй для:
| Тип модели | Contract? | Почему |
|---|---|---|
Staging (stg_*) | Нет | Часто меняются, internal layer |
Intermediate (int_*) | Нет | Эфемерные, не consumer-facing |
| Marts (consumer-facing) | Да | Stability для BI/ML |
| Public API tables | Обязательно | Контракт с external systems |
| Shared facts/dims (cross-team) | Обязательно | Multi-consumer stability |
Snapshot tables (*_snapshot) | Да | History не должна терять колонки |
Эвристика: если 3+ downstream используют модель — contract. Меньше — overhead.
Полный пример contract
# models/marts/_marts__models.yml
version: 2
models:
- name: fct_orders
description: "Production fact table для заказов. Used by 15 dashboards."
config:
contract:
enforced: true
materialized: table
columns:
- name: order_id
data_type: bigint
description: "Primary key. Unique per order."
constraints:
- type: not_null
- type: primary_key
data_tests: [unique, not_null]
- name: customer_id
data_type: bigint
description: "FK on dim_customers"
constraints:
- type: not_null
- type: foreign_key
expression: ref('dim_customers')
references_field: customer_id
data_tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
- name: order_date
data_type: date
description: "Date of order placement"
constraints:
- type: not_null
- name: order_total
data_type: numeric(12, 2)
description: "Order total in USD"
constraints:
- type: not_null
- type: check
expression: "order_total >= 0"
- name: status
data_type: varchar(20)
description: "One of: completed, pending, cancelled, refunded"
constraints:
- type: not_null
- type: check
expression: "status IN ('completed', 'pending', 'cancelled', 'refunded')"
data_tests:
- accepted_values:
values: ['completed', 'pending', 'cancelled', 'refunded']
-- models/marts/fct_orders.sql
SELECT
o.order_id::bigint,
o.customer_id::bigint,
o.order_date::date,
o.order_total::numeric(12, 2),
o.status::varchar(20)
FROM {{ ref('stg_orders') }} o
WHERE o.order_total IS NOT NULL
AND o.status IS NOT NULL
Чтобы contract прошёл, SQL должен:
- Содержать ровно те колонки, что в YAML (нет extras, не пропустить declared).
- Колонки иметь точные типы (через
::casts если нужно). - Соответствовать constraints в данных (NOT NULL — WHERE filter, или COALESCE).
При dbt run:
- dbt компилирует SQL.
- Проверяет actual columns/types vs YAML declarations.
- При mismatch — build fails с подробным сообщением.
- Применяет DDL constraints (где supports warehouse).
DuckDB и constraints: partial support
В курсе мы используем DuckDB. Constraints support:
| Constraint | DuckDB |
|---|---|
NOT NULL | ok |
PRIMARY KEY | ok |
CHECK | ok |
UNIQUE | ok |
FOREIGN KEY | partial (см. ниже) |
FOREIGN KEY на DuckDB:
- Поддерживается в локальной DuckDB
- Не поддерживается на MotherDuck (cloud version)
- При cross-database refs (attach Postgres) FK не enforced
Это означает: contract на DuckDB declares FK в metadata, но enforcement зависит от scenario. На MotherDuck FK становится declarative-only — нужны data tests relationships для actual enforcement.
В Snowflake / Postgres / BigQuery — full FK support, но Snowflake/BigQuery — metadata-only (DDL DECLARE FK, но не enforced — это для documentation/optimizer hints).
В большинстве warehouses (Snowflake, BigQuery) constraints в model contract — metadata-only. PK / FK / CHECK объявляются в DDL, но warehouse не блокирует insertions, нарушающих constraints. Это false sense of security: contract объявляет, но проверка fact-данных — задача data tests. Только Postgres / DuckDB (locally) enforce CHECK / FK.
Антипаттерны
-
Contract на каждой модели: добавляет overhead в YAML без ROI на эфемерных моделях. Использовать только на consumer-facing.
-
Contract без точных типов:
data_type: numericбез precision. dbt parse ok, но семантически — broken. Всегдаnumeric(precision, scale). -
Contract + breaking change: removed колонку из SQL, обновил YAML. Build pass, но downstream ломается. Нужны model versions (следующие уроки).
-
Contract без consumer notification: добавил/удалил колонку через contract, не сообщил consumers через exposures. Downstream ломается на проде.
-
Contract на view materialization: некоторые DDL constraints (PK, FK) не работают на views в некоторых warehouses. Test перед prod.
-
Constraints вместо tests:
NOT NULLconstraint в YAML -> false sense of security в Snowflake (metadata-only). Дублирование через data testnot_nullобеспечивает real check. -
Numeric precision drift:
numeric(12, 2)в YAML,numeric(10, 4)в SQL — contract caught. Но если обаnumericбез precision — silent drift. Always specify precision/scale.
Migration: enable contract на existing model
Не делать в один PR. Phased approach:
-
PR 1 — добавить YAML declarations (без
enforced: true):columns: - name: order_id data_type: bigint constraints: [{type: primary_key}] ...dbt parse runs, no enforcement. Можно review declarations.
-
PR 2 — align SQL с YAML (если есть drift):
SELECT order_id::bigint, ...Cast all columns to declared types. Run + tests.
-
PR 3 — enable contract:
config: contract: enforced: trueBuild должен пройти. Если нет — drift remained, fix.
-
CI lock: добавить gate, что
enforced: trueнельзя отключить без specific approval.
Это safe migration — постепенный rollout с rollback на каждом шаге.
Попробуй сам
В своём dbt-проекте:
-
Выбрать критическую mart-модель (например,
customer_metricsилиfct_orders). -
Phase 1: добавить полное declaration в YAML без
enforced: true:- Все колонки с
data_type - PK / FK constraints
- CHECK constraints где meaningful (revenue не меньше 0)
- Все колонки с
-
Phase 2: align SQL — добавить
::typecasts, убедиться что нет лишних колонок. -
Phase 3: включить
enforced: true. Run. -
Tester: попробовать сломать contract:
- Изменить тип колонки в SQL (
numeric -> float) — build должен fail - Добавить лишнюю колонку в SQL — build должен fail
- Изменить тип в YAML без SQL update — build должен fail
- Изменить тип колонки в SQL (
-
Recover: restore correct alignment, run snova — pass.
-
Observe error messages: они точно показывают, где mismatch.
Ключевые выводы
- Model contract (1.5+) — enforced schema declaration. Build fails при mismatch SQL vs YAML.
- Что проверяет: presence of columns, types, constraints (PK/FK/CHECK/NOT NULL).
- Не проверяет: data quality (это data tests), correctness logic (это unit tests), backward compatibility (это model versions).
- Когда нужен: marts (consumer-facing), public API tables, shared cross-team models. Эвристика — 3+ downstream consumers.
- DuckDB constraint support: partial — FK работает локально, не на MotherDuck. Snowflake/BigQuery — metadata-only (declarative, не enforced).
- Phased enable: PR 1 declare без enforce -> PR 2 align SQL -> PR 3 enable enforced. Safe rollout.
- Антипаттерны: contract на всех моделях, без точных типов (precision), без consumer notification, на views (problematic), как замена data tests (constraints metadata-only).
- Production = contract + data tests + unit tests — три слоя для разной защиты.