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.
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,incrementalorview(ephemeralmodels 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.
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
numericand servedfloat64is 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: publicfor other projects, a contract is basically the least you owe them. Pair it with modelversionswhen you do need a breaking change: publishv2, givev1a 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.