dbt

dbt Model Contracts: Using Schema and Type Contracts Correctly

2026-04-19
NicheeLab Editorial Team

When a model's output schema drifts, downstream dashboards and ML pipelines break. dbt Model Contracts make the column structure and data types of a model explicit as a "contract" so mismatches between implementation and schema are detected and prevented early.

This article, based on stable features in the official dbt documentation, organizes how to write contracts, their runtime behavior, how they differ from tests and DB constraints, and the key points for CI implementation, from both the Analytics Engineer exam perspective and real-world operations. Refer to dbt Docs for detailed specifications and the latest adapter support.

Overview and Scope of Model Contracts

Model Contracts let you declare the output schema of a model (column names, order, and data types) in YAML. When contract.enforced is turned on, dbt adjusts the SQL at build time (projecting and casting) to satisfy the contract, and fails the build on any mismatch. This prevents upstream changes from propagating silently to downstream consumers.

There are three main benefits: 1) schema lock-in (early detection of breakage), 2) explicit types (suppressing implicit casts), and 3) easier change review (diffs surface in YAML). Note that ensuring the quality of the data values themselves is the realm of tests and DB constraints.

  • What is locked down: column names, order, and data types (with casts as far as the adapter allows)
  • Mismatches that can be detected: extra or missing columns, and types that differ from expectations
  • Out of scope: value validity and referential integrity (the role of tests and DB constraints)

Contract flow (development → CI → execution platform)

PRdbt buildDeveloper ChangeSQL + YAMLCI (dbt run/build)with contractData WarehouseTable/ViewLocal testdbt build / fix mismatchContract mismatchCI fail (fail fast)Relationfixed schema (cast/project)Developer → CI (contract validation) → Data Warehouse: CI blocks mismatches; the schema is applied only on success

How to Write a Contract (YAML and Model Configuration)

Contracts are declared in a model's YAML file with column definitions and types, and applied by enabling contract.enforced. Write data types using the type names of the warehouse you are using. Type aliases are accepted by many adapters, but avoid ambiguous expressions and verify with compile/build in the actual environment for safety.

Contracts can be enabled per model or per package (via the model selector in dbt_project.yml). A practical organizational standard is to require contracts on every public model that is consumed downstream (for example, marts).

  • Always specify columns[].name and columns[].data_type in YAML
  • Enable contract.enforced: true at the model or hierarchy level
  • Use the adapter's native type names (Snowflake: NUMBER/VARCHAR, BigQuery: INT64/STRING, Postgres: BIGINT/TEXT, etc.)

Example: enabling contracts in schema.yml and dbt_project.yml

version: 2
models:
  - name: dim_customers
    description: Customer dimension (public)
    config:
      contract:
        enforced: true
    columns:
      - name: customer_id
        description: Customer ID unique within the business
        data_type: BIGINT   # Snowflake: NUMBER(38,0) also works
        tests:
          - not_null
          - unique
      - name: customer_name
        data_type: VARCHAR
      - name: is_active
        data_type: BOOLEAN

# dbt_project.yml (enable contracts by default under marts)
models:
  my_project:
    marts:
      +contract:
        enforced: true

Runtime Behavior (Type Casts, Column Order, and Mismatch Failures)

When the contract is enforced, dbt projects columns in the declared order from the model's SELECT and inserts type casts as needed, within what the adapter allows. Extra columns in the SELECT that are not part of the contract are dropped from the output. Missing columns or type mismatches that cannot be cast cause the build to fail.

Views also have their schema locked by column projection and casts. When creating a table, CREATE TABLE is performed using the declared types, typically followed by INSERT or CREATE OR REPLACE (the exact DDL depends on the adapter).

  • Extra columns: excluded from output (outside the contract)
  • Missing columns: failure (Missing column)
  • Uncastable: failure (e.g., explicitly casting 'abc' to INT)
FeaturePrimary purposeWhen it runsBehavior on failure
Model ContractsLock down schema (names, order, types)When dbt build/run materializes the modelMismatches fail the job. Extra columns are excluded; missing columns or unsupported casts error out
dbt TestsValidate value quality and relationshipsWhen dbt test runs (often in CI)Reports test failures. Model materialization has often already succeeded
DB constraints (NOT NULL, PK, etc.)Ensure consistency at the database levelWhen DML/DDL is applied (evaluated by the DB)The DB rejects the insert/update and the transaction fails

Example: specifying config directly inside the SQL model (casts are inserted depending on the adapter)

{{ config(materialized='view', contract={'enforced': True}) }}

with src as (
  select * from {{ ref('stg_customers') }}
)
select
  cast(customer_id as bigint)   as customer_id,
  cast(customer_name as varchar) as customer_name,
  cast(active as boolean)        as is_active
from src

Notes by Materialization (Table / View / Incremental)

Tables and views are generally the primary candidates for applying contracts. Views also have their schema locked by projection and casts, but if a type change in the underlying table propagates and becomes uncastable, the build fails.

For incremental models, adding columns or changing types on an existing table depends on the database's DDL compatibility. When you add a column via the contract, recreating with full-refresh as needed is the safer operational choice.

  • View: contract locks column order and types. DDL constraints are weaker, but changes are easier to roll out safely
  • Table: CREATE TABLE reflects the declared types. Incremental changes depend on whether the DB supports ALTER
  • Incremental: plan for full-refresh when schema changes are incompatible

Operational tip: release procedure notes by change type

- Compatible addition (appending columns): add to YAML first → build → notify downstream
- Compatible removal (removing unused columns): remove downstream references → remove from YAML → build
- Incompatible (type or column rename): fix downstream on the same branch → validate together in CI → full-refresh in production

Choosing Between Contracts, Tests, and DB Constraints

Contracts lock down "the shape of the output." Value quality is the job of tests, and enforcement on actual data operations is the job of DB constraints. Layering all three makes design intent explicit in YAML/DDL/tests and makes changes visible.

In practice, a stable approach is: require contracts on public models, ensure primary/foreign-key equivalents via tests, and leverage DB constraints (NOT NULL, unique) where possible.

  • Contracts: define the compatibility boundary of the schema (block breaking changes)
  • Tests: value quality and relationships (not_null, unique, relationships, etc.)
  • DB constraints: the last line of defense on actual data operations (mind adapter and edition differences)

Example: combining contracts and tests (ensure referential integrity with relationships)

version: 2
models:
  - name: fct_orders
    config:
      contract:
        enforced: true
    columns:
      - name: order_id
        data_type: BIGINT
        tests: [unique, not_null]
      - name: customer_id
        data_type: BIGINT
        tests:
          - not_null
          - relationships:
              to: ref('dim_customers')
              field: customer_id
      - name: order_total
        data_type: NUMERIC

CI Integration and Governance (Practical Points That Show Up on the Exam)

Make it mandatory to run dbt build on every PR and block contract mismatches. Contract changes (column additions, type changes, removals) always surface as YAML diffs in the PR, which makes review easier. Combined with catalog and documentation generation, you can deliver an API-like public contract.

To strengthen breakage detection, combine selectively running the public layer (marts) as the target, detecting schema changes in seeds and sources, and periodic full-refresh runs (to pay down incremental debt).

  • Use dbt deps → dbt build -s state:modified+ as the CI baseline
  • Track contract changes in a CHANGELOG and send release notes downstream
  • Coverage metrics: ratio of models with contracts applied, test coverage, CI failure rate

Example: a rough CI step in GitHub Actions

steps:
  - run: dbt deps
  - run: dbt build -s state:modified+ --profiles-dir .
  - run: dbt test -s tag:public
  # Block the PR on contract mismatches or test failures

Check Your Understanding

Analytics Engineer

問題 1

You want to add a new column customer_tier (string type) to the public model dim_customers. Which is the most appropriate procedure to release it safely using dbt Model Contracts while preserving downstream compatibility?

  1. Add the customer_tier column (with the appropriate data_type) to schema.yml in an appended position, run dbt build in CI on the PR, and then release to production
  2. Just add customer_tier to the SELECT in the model SQL. The contract will update automatically
  3. Only run dbt test, and release to production if there are no failures (update the contract later)
  4. Temporarily disable the contract for deployment, then re-enable it once things are stable

正解: A

The contract treats the columns declared in YAML as the source of truth. The safe path is to add the column to schema.yml with the appropriate data_type first, verify in CI that the contract is consistent and the type casts succeed, and then release. Changing only the SQL or deferring the contract leads to missed downstream compatibility issues.

Frequently Asked Questions

Can data_type use dbt's generic type names?

No. You should generally use the native type name of the warehouse you are using. Many adapters accept aliases, but to avoid ambiguity, run dbt build in your actual environment and verify the generated DDL and casts (see dbt Docs).

Why does the build pass when extra columns appear in SELECT even though a contract is enforced?

When the contract is enforced, only the declared columns are projected in the declared order, so columns outside the contract are dropped from the output. This is by design to preserve downstream compatibility. On the other hand, missing columns or impossible type casts cause the build to fail.

How should type changes be handled in incremental models?

Type changes on an existing table depend on the database's ALTER compatibility and carry significant operational risk. Plan to update the contract and then recreate the table with full-refresh as a rule, providing a migration window when necessary.

Check what you learned with practice questions

Practice with certification-focused question sets

無料で問題を解いてみる
Author

NicheeLab Editorial Team

NicheeLab editorial team focused on data engineering and cloud certification learning. Content is structured around practical study needs and official exam domains.


Related articles
dbt

dbt Models: SQL-Defined Transformation Units (2026)

Model fundamentals — SELECT-based definitions, naming, refs,...

dbt

dbt Analytics Engineering Exam: Complete Guide (2026)

Pass the AE Certification — scope, weighting, sample questio...

dbt

dbt Cloud vs dbt Core: Feature & Cost Comparison (2026)

Honest comparison of dbt Cloud vs. dbt Core — IDE, scheduler...

dbt

dbt Project Structure: models/seeds/macros Layout (2026)

Recommended dbt project layout — models, seeds, macros, snap...

dbt

dbt_project.yml Explained: Every Config (2026)

Every dbt_project.yml setting that matters — paths, vars, ma...

Browse all dbt articles (101)
© 2026 NicheeLab All rights reserved.