How Do You Catch Breaking Schema Changes That dbt Contracts Miss?
Renamed columns, downstream references and logic changes sit outside what a model contract checks
A dbt model contract only checks that one model's output matches the column names, data types and constraints you declared for it. The dbt model contracts documentation describes contracts as guarantees about the shape of a model's output, so anything outside that shape slips through: a renamed column that no downstream model has a contract on, a changed calculation that keeps the same type, and references in models that never opted in. Catching those takes a second layer of checks that works across models.
Key Takeaways
- •A dbt contract validates structure (column names, data types, constraints) for one model, and it does not validate the logic inside a column.
- •Contracts only apply to models that set
contract: enforced: true, and the dbt docs list Python models, ephemeral models, sources, seeds and snapshots as unsupported for this feature. - •A rename or a same-type logic change can pass a contract check and still change what every downstream consumer sees.
- •Closing the gap takes five layers: breaking-change diffing against production state, downstream reference checks, data diffs on changed logic, platform-aware constraints and a named owner who decides.
- •The decision to expand, deprecate or roll back stays with a person, while detection and blast-radius mapping can be automated.
What a dbt contract actually checks
When you set contract: enforced: true on a model, dbt requires every column to declare a name and a data_type. Before building, it runs a preflight check that the query returns columns matching those declarations, ignoring column order, and it writes the names, types and constraints into the DDL. If the model does not match, it does not build. The dbt docs draw the line clearly: contracts validate shape, while data tests validate content after the build.
The documentation also lists what counts as a breaking change when dbt compares against a previous project state: removing an existing column, changing a column's data type, and removing or modifying a constraint. The constraint rule applies from dbt v1.6, and the rule that removing, renaming or disabling a contracted model is breaking applies from dbt v1.9. Versioned models raise an error in that case, while unversioned models raise only a warning. That is useful coverage, and we want to be fair about it: for a model that has opted in, a dropped column is caught at build time.
The limits follow from the same page. Supported materializations are table, view and incremental (with on_schema_change set to append_new_columns or fail). Python models, materialized views, ephemeral models and custom materializations are not supported unless the author adds support. Sources, seeds and snapshots do not support these governance features either. Every one of those is a place where a column can change without a contract noticing.
Why renamed columns and logic changes get through
A rename is a removal plus an addition. On a model with an enforced contract, the preflight check fails when the SQL no longer returns the declared column name, so the author is forced to update the YAML at the same time. That is the catch: the person who renames the column edits the contract in the same pull request, and the contract now passes. The check confirms the model is consistent with itself. It does not tell you which of the 40 downstream models still select the old name.
Logic changes are a harder case. In a post titled the false promise of dbt contracts, Toby Mao puts it this way: "dbt's model contracts don't take into account logic, only structure." His example changes 1 + 1 to 1 + 2 in an upstream model. The schema is identical, so a structural check passes, yet every model that consumes the value now computes something different. The same post notes that dbt's own writing defines large projects as those with more than 1000 models, which is the scale at which nobody reads every diff by hand.
Note that the author works on a competing transformation framework, so treat the framing as one practitioner's argument rather than neutral research. The structural point itself is consistent with the dbt documentation, which says the same thing in more measured words. A contract is a schema check. Teams run into trouble when they read it as a safety net for semantics, because a column named revenue that switches from gross to net keeps its name and its numeric type.
The five-layer check that covers the gap
We think of the gap as five layers, each answering a different question. None replaces the dbt contract. Each one sits next to it and catches what it cannot. The order below is the order we would add them to a pull request pipeline, starting with the cheapest.
Layer 1 is breaking-change diffing against production state. Run your CI against the production manifest so dbt can compare the proposed project to what is deployed, and treat contract errors as blocking. This catches dropped and retyped columns on models with enforced contracts. Layer 2 is a downstream reference check: for every column the pull request removes or renames, list the models, exposures and dashboards that reference it. A contract cannot do this because it only knows about one model. Column-level lineage, whether parsed from your dbt manifest or from warehouse query history, supplies the list.
Layer 3 is a data diff on changed logic. Build the changed model and its direct children in a CI schema, then compare row counts, null rates and aggregate values against production for the affected columns. This is how you catch the 1 + 1 to 1 + 2 case, because the result changes even though the schema does not. Layer 4 is platform-aware constraints. The dbt docs show that constraint enforcement differs by warehouse adapter, with some constraints definable but not enforced, so confirm which of your declared constraints the warehouse actually checks. Layer 5 is a named owner for each decision, so a flagged change reaches someone who can approve, defer or reject it.
Where each layer fits in a pull request
Layers 1 and 2 are fast and belong on every pull request that touches a model or a column. They need no warehouse compute beyond what compiling the project already costs, and their output is a list rather than a pass or fail. A useful format is a comment on the pull request that says: this change removes orders.customer_ref, which 7 downstream models and 2 dashboards reference. The reviewer then decides whether the change is intentional.
Layer 3 costs compute, so scope it. Run data diffs only on models whose compiled SQL changed and on their direct children, and sample large tables. The goal is a signal, not a full reconciliation. Open-source contract tooling can help on the declaration side: the datacontract-cli repository on GitHub is an example of a command-line tool that lints contract files, though like dbt contracts it checks what is declared rather than what a downstream query selects.
Layers 4 and 5 are policy, not tooling. Write down which constraints your warehouse enforces and which are documentation only, and record an owner for every contracted model. When a pull request removes a column, the flagged list should route to the owners of the models and dashboards it affects. Without that routing, the check produces a comment nobody feels responsible for, which is the failure mode most teams see after adding automated checks.
What changes when an agent runs the checks
Layers 1 to 3 are mechanical, which makes them good candidates for automation. The Schema Agent detects column-level drift, maps which downstream assets reference a changed column, and drafts a safe migration. A reader can ask it in plain language: what's the blast radius if I drop this column, or is the customer_id join safe after the migration. The answer is a list of affected models and consumers, produced before the merge instead of after the page.
The Pipeline Agent can then apply the expand-and-contract sequence that makes a rename safe: add the new column, backfill, migrate consumers, then drop the old one. We described that sequence in our post on three response policies for schema changes. Each step is a separate pull request with its own checks, so a bad rename never lands as one large change.
What stays with a person is the judgment call. Whether revenue should now mean net revenue is a business decision, and no schema check can answer it. In our pilot testing we keep writes behind approval, and the OSS edition is read-only by default, so the agent reports and proposes rather than changes production models on its own. The autonomy dial per domain lets a team decide how far that goes.
A rollout order for a team that already has contracts
Start by finding which models have no contract at all. In most projects the contracted models are the public, mesh-facing ones, and the staging and intermediate layers have none. List the unenforced models that sit between a source and a contracted model, because that is where a rename travels unnoticed before it reaches the contract boundary.
Next, turn on the production-state comparison in CI and make contract errors blocking. Then add the downstream reference comment from Layer 2, even if it is only a grep over the compiled manifest at first. Teams that skip straight to data diffs often find the diffs noisy, because they have not yet separated intentional changes from accidental ones. The reference list is what lets a reviewer say a change is intentional.
Finally, add data diffs on the handful of models whose columns feed revenue, billing or customer-facing metrics. You do not need them on every model. Pick the ten columns that would cause an escalation if they changed silently, and cover those first. Expand only when the checks have earned the team's trust, which usually means a quarter of low false-positive output.
Frequently Asked Questions
Does a dbt contract catch a renamed column?
Only in part. If the model has an enforced contract and the SQL returns a different name than the YAML declares, the build fails, so an accidental rename is caught. A deliberate rename that updates the YAML in the same pull request passes. The contract does not tell you which downstream models still reference the old name, and models without enforced contracts get no check at all.
Do dbt contracts detect logic changes?
No. They compare names, data types and constraints. Changing a calculation while keeping the column name and type passes unnoticed, which is the point made in the Tobiko post on dbt contracts. Catching it requires comparing output values between the proposed build and production, usually with a scoped data diff on the changed model and its direct children.
Which models can't have a dbt contract?
According to the dbt model contracts page, Python models, materialized views, ephemeral models and custom materializations are not supported unless the materialization author adds support. Sources, seeds and snapshots do not support governance features such as contracts. For those, rely on source freshness checks, schema tests and the downstream reference check described above.
Can an agent block a breaking change before it merges?
It can report the blast radius and recommend blocking, and in a Pro setup it can post that as a required check. The final approve-or-reject decision stays with the model owner. We keep that boundary on purpose, because the same column change can be correct for one team and damaging for another.