A shared mart such as dim_customers has dozens of consumers: BI dashboards, reverse ETL syncs, other dbt projects, notebooks. A column rename merged on a Tuesday breaks all of them, and nobody notices until a dashboard turns blank. dbt has two features that turn this into a managed change. Contracts make the build fail if a model's output shape drifts from the declared one. Versions let v1 and v2 run side by side while consumers migrate.
Step 1: put an enforced contract on the model
# models/marts/_marts.yml
models:
- name: dim_customers
access: public
config:
contract:
enforced: true
materialized: table
columns:
- name: customer_id
data_type: bigint
constraints:
- type: not_null
- type: primary_key
- name: customer_name
data_type: varchar
- name: segment
data_type: varchar
- name: created_at
data_type: timestamp
With enforced: true, dbt runs a pre-flight check before it builds the model. The model's SQL must return exactly these column names with compatible data types. A missing column, an extra column or a type mismatch fails the build before anything is written, and the error message lists the differences.
Tip
Write
data_typethe way your warehouse spells it (number(38,0)vsbigint,stringvsvarchar). dbt compares data types through the adapter, and some adapters compare only the type name, not precision or length. Rundbt build -s dim_customersonce after you write the YAML and fix every mismatch it reports.
What constraints actually do
Constraints are sent to the platform as DDL. Whether the platform enforces them depends on the platform:
| Platform | not_null | primary_key / unique / foreign_key |
|---|---|---|
| Postgres | Enforced | Enforced |
| Snowflake | Enforced | Accepted but not enforced |
| Databricks (Delta) | Enforced | Informational only (Unity Catalog) |
| BigQuery | Enforced | Not enforced |
| Redshift | Enforced | Informational |
Check your adapter's documentation for the current behaviour. Wherever a constraint is informational, keep a matching data test (unique, relationships), because nothing else will catch duplicates.
Contract limits to know
- Contracts apply to SQL models materialized as
table,vieworincremental. Ephemeral models can't have them, and Python model support varies, so check the docs for your version. - On views, dbt checks columns and types but can't apply constraints.
- On incremental models, set
on_schema_changetoappend_new_columnsorfail. Contract enforcement does not change the existing target table, so silent schema sync defeats the point.
{{ config(materialized='incremental', unique_key='customer_id', on_schema_change='fail') }}
Step 2: catch breaking changes in CI
In slim CI, dbt compares the contract against production's manifest:
dbt build --select state:modified+ --state ./prod-artifacts --defer
If a change to a contracted model removes a column, renames one or changes a data type, dbt reports a breaking contract change. Whether it is a warning or an error depends on your dbt version and on whether the model is versioned. Versioned models fail hard. Adding a column is not breaking.
Warning
State comparison needs a current
manifest.jsonfrom production. If CI defers to an old manifest, breaking changes can slip through, or valid PRs can be flagged. Publish the artifacts from every production run.
Step 3: ship a breaking change as a new version
Say segment is being replaced by segment_code plus segment_name. Don't edit the model in place. Add a version.
models/marts/dim_customers_v1.sql -- the current logic, unchanged
models/marts/dim_customers_v2.sql -- the new shape
models:
- name: dim_customers
access: public
latest_version: 1 # keep unpinned refs on v1 until consumers are ready
config:
contract: {enforced: true}
columns: # v2 shape is the base definition
- name: customer_id
data_type: bigint
constraints: [{type: not_null}]
- name: customer_name
data_type: varchar
- name: segment_code
data_type: varchar
- name: segment_name
data_type: varchar
- name: created_at
data_type: timestamp
versions:
- v: 2
- v: 1
deprecation_date: 2027-03-31
columns:
- include: all
exclude: [segment_code, segment_name]
- name: segment
data_type: varchar
Each version builds as its own relation, dim_customers_v1 and dim_customers_v2 by default. ref('dim_customers') resolves to latest_version. Consumers can pin a version explicitly:
select * from {{ ref('dim_customers', v=2) }} -- early adopters
select * from {{ ref('dim_customers', v=1) }} -- not migrated yet
Set latest_version: 2 once v2 is the default. Every unpinned ref moves at that point, so make that its own PR.
Selecting versions
dbt build -s dim_customers # all versions
dbt build -s dim_customers.v2 # one version
dbt ls -s "version:latest" # latest version of every versioned model
dbt ls -s "version:old" # versions older than latest
dbt ls -s "version:prerelease" # versions newer than latest_version
Step 4: give non-dbt consumers a stable name
BI tools and reverse ETL query relations, not refs. They need an unversioned name that follows latest_version. A post-hook can create a view for it:
-- macros/create_latest_version_view.sql
{% macro create_latest_version_view() %}
{% if model.get('version') and model.get('version') == model.get('latest_version') %}
{% set new_rel = this.incorporate(path={"identifier": model['name']}, type='view') %}
create or replace view {{ new_rel }} as select * from {{ this }}
{% else %}
select 1
{% endif %}
{% endmacro %}
models:
- name: dim_customers
config:
post_hook: ["{{ create_latest_version_view() }}"]
create or replace view works on Snowflake, Databricks, BigQuery and Postgres. Some platforms refuse to replace a view with one that has a different column list. If yours does, drop the view first. Now analytics.marts.dim_customers always points at the latest version.
Step 5: retire the old version
- Find v1's remaining consumers. Inside dbt, use
dbt ls -s "+dim_customers.v1+" --resource-type model. Outside dbt, check warehouse query history fordim_customers_v1. - dbt warns when a model refs a version that is deprecated or past its
deprecation_date. Turn those warnings into errors in CI if you want a hard cut-off. - After the date, delete
dim_customers_v1.sqland itsversionsentry. dbt does not drop the relation for you, so drop it in a controlled change, and keep a clone first if your platform supports zero-copy clones.
Common errors
| Error / symptom | Cause | Fix |
|---|---|---|
| Contract check fails on a column you didn't touch | Upstream type changed (for example int to bigint) | Cast explicitly in the model's final select |
This model has an enforced contract that failed listing type mismatches | YAML types don't match the warehouse spelling | Use the type names dbt prints in the error |
| Contract passes but duplicates appear | primary_key is informational on this platform | Add a unique data test |
| All consumers broke when v2 shipped | latest_version was bumped in the same PR as v2 | Release v2 as a prerelease and bump the default later |
| v1 relation still queried months later | No unversioned view; BI pointed at _v1 | Use the latest-version view and move BI to it |
Prevent it next time
- Put an enforced contract on every
publicmodel. Makeaccessand contracts part of PR review for the marts layer. - Never rename or retype a public column in place. Breaking change means a new version with a deprecation date.
- Publish production artifacts on every run so state-based CI checks contracts against reality.