An innocent-looking change to a mart can break dashboards three tools away: rename a column, widen a type, and every consumer finds out after the fact. The idea behind dbt model contracts is simple: the schema of a table is a promise, and until something enforces that promise, it is being kept by luck.

Here is how contracts work, how to configure them, and where they are worth the friction.

The problem: schemas drift silently

A mart is never consumed by nobody. A BI dashboard reads it, a scoring job selects three columns from it, a finance export depends on amount being numeric. None of those consumers is visible from inside your .sql file.

So when a refactor changes amount from NUMERIC to FLOAT64, or an upstream SELECT * quietly adds a column, dbt happily builds the table. The promise changed and nobody signed off. Downstream tools discover it at their own pace, usually in production, usually at the worst time.

fct_payments producer model CONTRACT names · types constraints BI dashboards ML feature jobs finance exports breaking change? the build fails here, not in production
A contract turns a table into an interface. Consumers depend on the contract, not on whatever the SQL happens to produce today.

What a contract actually is

Since dbt 1.5, you can declare that a model has an enforced contract: an explicit list of column names, data types and constraints that the model guarantees. At build time, before creating the table, dbt compares what your SQL produces against what you promised. Any mismatch fails the run.

The key word is before. A failing contract means the old, correct table is still in the warehouse and your consumers never see the bad version.

Configuring one

Two files are involved. The model itself stays ordinary SQL:

-- models/marts/fct_payments.sql
select
    payment_id,
    loan_id,
    cast(amount as numeric)  as amount,
    currency,
    paid_at
from {{ ref('stg_payments') }}
where paid_at is not null

The contract lives in the YAML, next to the documentation you (hopefully) already write:

# models/marts/marts.yml
models:
  - name: fct_payments
    config:
      contract:
        enforced: true
    columns:
      - name: payment_id
        data_type: string
        constraints:
          - type: not_null
      - name: loan_id
        data_type: string
        constraints:
          - type: not_null
      - name: amount
        data_type: numeric
      - name: currency
        data_type: string
      - name: paid_at
        data_type: timestamp

Three rules to know before writing one:

  • Every column must be listed, with its data_type. A contract is all or nothing; you cannot contract half a table.
  • The model needs a real materialization: table, incremental or view (ephemeral models have nothing to contract). Constraints only apply to table-like materializations.
  • Types are compared as the warehouse sees them, so on BigQuery write numeric, float64, timestamp: the names from your platform, not abstract ones.

What failure looks like

Say someone changes the cast to float64. The run stops at compile time with a small table that says exactly what broke:

Compilation Error in model fct_payments
  This model has an enforced contract that failed.

  | column_name | definition_type | contract_type | mismatch_reason    |
  | ----------- | --------------- | ------------- | ------------------ |
  | amount      | FLOAT64         | NUMERIC       | data type mismatch |

No table was built, no dashboard went blank. The conversation about whether the change is legitimate happens in the pull request, where it belongs.

compile contract check schema, before the table exists build table dbt tests data, after the build
Contracts and tests are not rivals: they guard different moments of the same run.

Contracts are not tests (you want both)

The two are easy to confuse, and they answer different questions:

Contract dbt test
Checks the shape: names, types, constraints the content: values in rows
Runs before the table is (re)built after the table is built
On failure bad schema never lands bad data landed, you get alerted
Example amount must be numeric amount must never be negative

A contract cannot tell you that revenue doubled overnight, and a not_null test discovers nulls only after they arrived. Use contracts on the boundary, tests on the business rules.

One platform caveat: constraint enforcement depends on your warehouse. On BigQuery, not_null is real DDL; primary_key is metadata only, BigQuery does not enforce it. Keep your unique tests. dbt documents which constraints each adapter actually enforces, and it is worth the read.

Where contracts earn their keep

Use cases where contracts earn their keep:

  • Marts consumed outside your team. The BI tool, the finance export, another squad’s pipeline. Anywhere the consumer cannot see your PR.
  • Feature tables for ML. A model trained on numeric and served float64 is the kind of bug that produces no error, only bad predictions.
  • Incremental models. A silent type change can poison an incremental table in a way that is painful to repair. Failing the build is cheaper.
  • Public models in a dbt mesh. If you mark a model access: public for other projects, a contract is basically the least you owe them. Pair it with model versions when you do need a breaking change: publish v2, give v1 a deprecation date, migrate consumers calmly.

And where they are usually not worth it: staging and intermediate models. They change every week, nobody outside the project reads them, and spelling out forty columns of YAML to protect an audience of zero is friction without a payoff. A contract is a commitment; commit where someone depends on you.

Closing thought

Contracts fit a simple way of working: make the promise explicit, let the machine hold you to it, and save human review energy for the things machines cannot check. They will not catch bad data, and they add friction where nobody is listening. Used on real boundaries, they turn schema changes from silent accidents into explicit decisions.

Corrections and sharper patterns are welcome: the contact form on this site exists for that.