Once contracts are in place, schema changes require more thought. You can’t just rename a column and run dbt build anymore — the contract catches the mismatch and the build fails. dbt’s model versioning system provides the mechanism for evolving contracted schemas without breaking downstream consumers.
Breaking vs Non-Breaking Changes
dbt’s state:modified selector detects five categories of breaking changes when comparing against a previous project state:
- Removing a column
- Changing a column’s data type
- Removing or modifying constraints
- Removing contract enforcement entirely
- Deleting or renaming a contracted model
Non-breaking changes pass cleanly without triggering errors. Adding a new column, fixing a calculation without changing types, or adding new constraints all work. You update the YAML, update the SQL, and the contract stays valid.
The distinction matters for CI/CD. Breaking changes require versioning. Non-breaking changes just need a contract YAML update alongside the SQL change.
How Versions Work
For breaking changes, dbt expects you to create a new model version. Versions use simple integers, and each version materializes as a separate table (mrt__analytics__customers_v1, mrt__analytics__customers_v2):
models: - name: mrt__analytics__customers latest_version: 2 config: contract: {enforced: true} columns: - name: customer__id data_type: int - name: customer__name data_type: string - name: customer__email_address data_type: string versions: - v: 1 deprecation_date: "2025-12-31" columns: - include: all exclude: [customer__email_address] - name: customer__email data_type: string - v: 2Version 2 uses the top-level column definitions (with customer__email_address). Version 1 includes all columns except customer__email_address and adds the old customer__email column instead. Both versions materialize as separate tables, both with their respective contracts enforced.
Unpinned ref('mrt__analytics__customers') resolves to latest_version. Consumers that need the old schema can pin with ref('mrt__analytics__customers', v=1). Old versions get a deprecation_date, and dbt warns when anything references a deprecated version. This gives downstream consumers time to migrate.
The Migration Window
The versioning pattern creates a migration window:
- Define v2 with the new schema alongside v1
- Set
deprecation_dateon v1 to communicate the timeline - Deploy both versions — they materialize as separate tables
- Notify consumers — dbt generates deprecation warnings in build output
- Remove v1 after the deprecation date passes and all consumers have migrated
In a dbt Mesh setup, this becomes especially valuable. When another team uses ref('your_project', 'mrt__analytics__customers'), versioning lets you evolve your schema without breaking their builds. The cross-project ref resolves to latest_version, but they can pin to a specific version if they need time to adapt.
The Friction Points
One real friction point: dbt’s breaking change detection is considered too aggressive by many users (GitHub issue #8028). It triggers errors even when you’ve intentionally updated the contract YAML to reflect a desired change, requiring model versioning even for simple precision corrections. You sometimes end up versioning for changes that feel more like fixes than breaking changes.
For example, changing numeric to numeric(38,2) to fix silent rounding is technically a type change. The state:modified selector flags it as breaking, requiring a new version — even though the change is corrective and consumers would benefit from it immediately. The intent behind the strictness is sound (any type change could break a consumer), but the lack of a “this is intentional, skip versioning” escape hatch creates overhead for routine maintenance.
The community has proposed several solutions: an --allow-breaking flag for CI, a breaking: false annotation in YAML, and more granular detection that distinguishes between widening changes (safe) and narrowing changes (unsafe). As of early 2026, none have been merged.
Schema Change Handling for Incremental Models
For incremental models with contracts, the on_schema_change configuration matters:
append_new_columns— Adds new columns to the existing table. Compatible with contracts because it only widens the schema.fail— Fails the build if the schema has changed. The safest option when contracts are enforced.sync_all_columns— Avoid this. It removes columns not present in the latest run, which creates exactly the kind of breaking change contracts are meant to prevent. An incremental run that drops a column will violate the contract on the next full refresh.ignore— Silently ignores schema changes. Risky with contracts because it allows the actual table schema to drift from the contracted schema over time.
The recommended combination for contracted incremental models is on_schema_change: 'fail' during normal operation, with deliberate schema migrations handled through versioning when changes are needed.
When to Version
Not every schema change requires versioning. The decision framework:
Version when:
- Removing a column that consumers depend on
- Changing a column’s data type in a way that could break consumers (e.g.,
integertostring) - Renaming a column (it’s a removal + addition from the consumer’s perspective)
- Changing semantics even if the type is the same (e.g.,
amountchanges from cents to dollars)
Don’t version when:
- Adding a new column (non-breaking by definition)
- Adding or strengthening constraints (makes the contract stricter, doesn’t break consumers)
- Fixing a calculation that doesn’t change the output type
- Updating descriptions or metadata
The goal is to version only when the change could cause a downstream failure. If a consumer’s query would break, version. If it would just get an extra column they don’t use, don’t.