Home / Data Platform / dbt & Quality

Data PlatformHow-toAdvanced

dbt model contracts and versions: enforce a public schema and ship breaking changes without breaking consumers

Use enforced dbt contracts so a model's columns and types are checked at build time, then use model versions, deprecation dates and a latest view to change a widely used model safely.

Deepak Bhardwaj7 min read

Applies todbt Core 1.6+ and dbt platform (contracts and versions from 1.5; deprecation_date from 1.6); Snowflake, Databricks, BigQuery, Postgres, Redshift adapters

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_type the way your warehouse spells it (number(38,0) vs bigint, string vs varchar). dbt compares data types through the adapter, and some adapters compare only the type name, not precision or length. Run dbt build -s dim_customers once 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, view or incremental. 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_change to append_new_columns or fail. 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.json from 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

  1. Find v1's remaining consumers. Inside dbt, use dbt ls -s "+dim_customers.v1+" --resource-type model. Outside dbt, check warehouse query history for dim_customers_v1.
  2. 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.
  3. After the date, delete dim_customers_v1.sql and its versions entry. 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 public model. Make access and 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.