How Do I Stop Upstream Schema Changes From Breaking Downstream dbt Models? Contracts, Versioning and Deprecation Windows
Contracts, versions and deprecation windows that turn a surprise column rename into a scheduled migration
The reliable way to stop upstream schema changes from breaking downstream dbt models is to make the interface explicit and enforced: declare a data contract on every model other teams depend on, publish breaking changes as a new version, and keep the old version alive through a dated deprecation window. dbt supports all three natively through model contracts and model versions. The rest of this guide walks through the five steps, what each one catches, and where a human still has to decide.
Key Takeaways
- •A schema change breaks dbt models when the interface between producer and consumer is implicit; a contract makes it explicit and testable.
- •dbt model contracts fail the build at compile or run time when a model's columns or data types differ from what was declared, so drift is caught before consumers see it.
- •Breaking changes should ship as a new model version with a deprecation date, never as an in-place edit to the existing model.
- •You can only set a safe deprecation window if you know who still reads the old shape; Snowflake's ACCESS_HISTORY view keeps 365 days of that evidence.
- •Detection is the easy part; the expensive part is deciding blast radius and coordinating the migration, which is where agents help.
Why upstream schema changes break dbt models
Most breakage comes from a gap in agreement, not a gap in tooling. An application team renames a column, changes an integer to a string, or drops a field that looked unused. The source table changes, the staging model selects a column that no longer exists, and the failure surfaces three hops later in a dashboard. Nobody did anything unreasonable; there was simply no written statement of what the producer promised to keep stable.
The DataHub guide on handling schema evolution and breaking changes frames the same problem from the metadata side: you need to know a change is coming, understand which assets depend on the changed field, and communicate before the change lands. That sequence, detect then assess then communicate, is the backbone of everything below.
It helps to separate additive changes from breaking ones. Adding a nullable column is almost always safe. Renaming, dropping, retyping, or changing the meaning of a column (for example, switching revenue from gross to net) is breaking. The meaning change is the nasty one, because no schema diff will flag it. That is why a contract needs a human-readable description and an owner, not only a column list.
Step 1: Write the contract down
Start with the models that cross a team boundary. For each one, declare the columns, types, nullability, and the checks that must hold. In dbt this is a contract: {enforced: true} block in the model's YAML plus a full list of columns with data_type. With enforcement on, dbt compares the declared shape to the shape the SQL actually produces and stops the build when they disagree.
If your contracts need to live outside dbt, for example because the producer is a service team that does not run dbt, the open-source datacontract-cli works from a YAML data contract file and can lint it and test it against the live source. That gives the producer a file they can review in their own repository, which matters more than which tool parses it.
Keep the first contract small. Cover the columns that downstream models actually select, the grain of the table, a freshness expectation, and a named owner. Teams that try to specify every column of every table on day one stall. A contract on twenty high-fanout models is worth more than a half-finished contract on two hundred.
Step 2: Enforce it where the change happens
A contract that only runs in production is a smoke alarm in the next building. Run contract checks in the pull request that changes the model, and run source-level checks on a schedule against the upstream tables. The first catches changes you make; the second catches changes other people make to what you read.
For incremental models, set on_schema_change deliberately rather than leaving the default. The default ignores new columns silently, which hides drift. Choosing fail for contracted models turns drift into a loud, early failure, while append_new_columns suits models where additive changes are explicitly welcome.
Be honest about what enforcement does not cover. A dbt contract checks names and types at build time. It does not check that values are sensible, that a column kept its meaning, or that the producer told anyone. Pair the contract with constraints and tests for value-level rules, and keep the description field current so a meaning change shows up in review.
Step 3: Version breaking changes instead of editing in place
When a breaking change is genuinely needed, publish it as a new version of the model. dbt's model versions let orders exist as v1 and v2 side by side, with a latest_version pointer and a ref that consumers can pin to a specific version. Consumers who pin keep building; consumers who follow latest take the change on purpose.
The version boundary is where you document the diff. List the removed, renamed, and retyped columns, give the reason, and note any change in meaning. Treat the version bump as the moment the contract is renegotiated, and require the downstream owners you identified to acknowledge it in the pull request.
Resist versioning for every change. Versions are a cost: two physical tables, two sets of tests, two things to explain. Reserve them for breaking changes on contracted models, and let additive changes flow through as ordinary edits to the current version.
Step 4: Set a deprecation window you can defend
A version is only half a migration; the other half is a date. dbt lets you set a deprecation_date on the old version, and it emits warnings to anyone still referencing it as the date approaches. The question is how long the window should be, and the honest answer is: as long as it takes the slowest real consumer, not a number you picked in a meeting.
That means measuring who still reads the old shape. In Snowflake, the ACCESS_HISTORY view records which columns each query touched, and Snowflake documents a 365-day retention for it. A year of history is enough to catch the quarterly finance report and the annual audit query that a 30-day look-back would miss.
Once you have the consumer list, split it into three groups: dbt models you can migrate yourself, dashboards and notebooks owned by named people, and unknown readers. Give each owned consumer a specific date, and treat unknown readers as a reason to extend the window or add a deliberate brownout, not as a reason to ignore them.
Step 5: Close the loop from detection to fix
Everything above reduces the number of surprises, but it does not remove the work between a detected change and a finished migration. Someone still has to map the blast radius, open the pull requests, update the pinned refs, and tell the owners. In our experience that coordination, not the detection, is where the hours go; we put a typical incident at 2 to 4 hours of human labor.
This is the job our agents are built for. The Schema Agent detects column-level drift and projects which downstream models and dashboards are affected, and the Pipeline Agent can draft the migration changes for review. You can ask it in plain language from Claude Code or Cursor: "What's the blast radius if I drop this column?" or "What changed in the orders schema since last deploy?"
The OSS edition is read-only by default, so it reports and recommends. Writes, approval workflows and audit trails come with Pro. In early pilot testing, 60% of incidents auto-resolve before a human is notified; that is our pilot data, not an industry benchmark, and schema migrations that change meaning still go to a person for sign-off.
A minimal rollout plan
If you want to start this week, pick the five dbt models with the most downstream references and put enforced contracts on them. Run the contract check in CI. Add a scheduled check against the upstream source tables that feed them, so you learn about producer changes the day they happen rather than the day a dashboard breaks.
Next, agree on a short written policy with the producing teams: what counts as breaking, how much notice they give, and who gets pinged. A one-page policy that people follow beats a thorough one they never read. Put the contract file in a repository the producer can edit, so changes to it become a review conversation.
Finally, rehearse the process once on a low-stakes change. Version a model, set a deprecation date, pull the consumer list from access history, and migrate one downstream owner. The first run will expose the gaps in your policy, and it is far cheaper to find them on a rehearsal than on a Friday-evening column drop.
Frequently Asked Questions
What is the difference between a data contract and a dbt test?
A test checks that data meets a rule after it is built, such as uniqueness or accepted values. A contract declares the shape and guarantees of a table as an agreement between producer and consumer, and dbt's enforcement fails the build if the model's columns or types differ from what was declared. You want both: contracts for the interface, tests for the values inside it.
How long should a deprecation window be?
Long enough for your slowest real consumer to migrate. Pull a full year of column-level access history where your warehouse provides it, since Snowflake keeps 365 days in ACCESS_HISTORY, and check for periodic readers such as quarterly reports. Then give each named owner a specific date and treat unidentified readers as a reason to extend or run a brownout.
Do I need model versions for every schema change?
No. Additive, nullable columns can usually ship as normal edits. Use a new version for removals, renames, type changes and changes in meaning on contracted models. Every version adds a second table and a second set of tests to maintain, so reserve them for changes that would genuinely break a consumer.
Can I enforce contracts on sources I do not control?
You can detect violations even when you cannot prevent them. Run scheduled checks against the source tables and compare them to a contract file, for example with the open-source datacontract-cli, then alert the producing team. Prevention needs the producer's agreement, so pair detection with a written change-notice policy.