Schema changes that break downstream consumers are one of the most disruptive problems in analytics engineering. Learn how to use dbt model contracts to enforce column-level guarantees at compile time and manage breaking changes through a structured versioning system that gives consumers time to migrate.

You've just deployed a change to a widely-used dbt model — you renamed a column from customer_id to cust_id for consistency, updated the downstream mart, and called it a day. Two hours later, your Slack is on fire. The revenue dashboard in Looker is broken. A Reverse ETL job pushing data to Salesforce has silently started sending nulls. A data science team running a weekly model retrain is throwing key errors. You didn't break any dbt tests, and dbt run succeeded cleanly. The problem is that you had no formal mechanism to communicate that this change was breaking — or to protect consumers who weren't ready for it.
This is the exact problem that dbt's model contracts and versioning features were built to solve. Introduced in dbt Core 1.5 and significantly matured through 1.6 and beyond, these features let you declare explicit column-level schema guarantees that dbt enforces at compile time, and manage breaking changes through a versioning system that allows multiple model versions to coexist. By the end of this lesson, you'll understand not just how to configure these features, but why they work the way they do, and how to design a contract governance strategy that scales across teams and breaking changes.
What you'll learn:
contract block in your YAMLThis lesson assumes you're comfortable with dbt at an advanced level. You should understand:
models:, columns:, and tests: blocks)ref() and source())You should also have dbt Core 1.5 or higher (or dbt Cloud with a recent runtime). Model contracts and versioning are unavailable in earlier versions.
Before we configure anything, we need to be precise about what "contract enforcement" means in dbt's model, because there's a meaningful difference between what you might expect and what actually happens.
When you enable a contract on a dbt model, dbt performs compile-time schema enforcement. Specifically, when you run dbt run, dbt compiles the model's SQL and — before executing it — compares the inferred output columns against the columns declared in your YAML. If there's a mismatch in column name or data type, the run fails with a contract violation error. It does not execute the SQL and then check. The failure is pre-execution.
What dbt contracts enforce:
not_null constraints declared in the contract are enforced at the warehouse DDL level (for table materializations)What dbt contracts do not enforce:
dbt test)Key insight
Contracts are a compile-time schema assertion, not a runtime data quality assertion. Think of them as TypeScript's type checker for your SQL output: they catch structural mismatches before execution, but they don't guarantee the data inside meets quality rules. For runtime data quality, you still need Implementing dbt Tests and Data Quality Checks in Production Pipelines.
This distinction matters architecturally. Contracts protect consumers from silent schema changes. Tests protect consumers from silent data quality degradation. You need both.
Under the hood, dbt uses a comparison between the model's node contract (declared in YAML) and the relation's expected schema (inferred from the compiled SQL). For Snowflake, this means dbt resolves column types based on the SQL expressions in your SELECT. For computed columns, dbt may need to execute a DESCRIBE or CREATE TABLE AS SELECT ... LIMIT 0 to infer types — the specific behavior depends on your adapter.
For incremental models, the contract is checked against the incremental query, not just the initial build. This is important: if you add a WHERE clause in your incremental logic that excludes certain columns, you can still trigger contract violations.
Let's work through a realistic example. Suppose you're an analytics engineer at a B2B SaaS company. You have a fct_subscriptions model in your marts/billing/ directory that's consumed by:
This model is a first-class contract candidate.
Here's what the model's SQL looks like:
-- models/marts/billing/fct_subscriptions.sql
with subscriptions as (
select * from {{ ref('int_subscriptions_enriched') }}
),
final as (
select
subscription_id,
customer_id,
plan_name,
plan_tier,
mrr_amount,
started_at,
ended_at,
is_active,
created_at,
updated_at
from subscriptions
)
select * from final
Now let's configure the contract in the corresponding YAML schema file:
# models/marts/billing/schema.yml
version: 2
models:
- name: fct_subscriptions
config:
contract:
enforced: true
materialized: table # contracts require table or incremental
columns:
- name: subscription_id
data_type: varchar
description: "Surrogate key for the subscription record"
constraints:
- type: not_null
- type: primary_key
- name: customer_id
data_type: varchar
description: "Foreign key to dim_customers"
constraints:
- type: not_null
- name: plan_name
data_type: varchar
description: "Human-readable plan name (e.g., 'Starter', 'Growth', 'Enterprise')"
- name: plan_tier
data_type: varchar
description: "Plan tier category"
- name: mrr_amount
data_type: numeric(18, 2)
description: "Monthly recurring revenue in USD"
constraints:
- type: not_null
- name: started_at
data_type: timestamp_tz
description: "Subscription start timestamp in UTC"
constraints:
- type: not_null
- name: ended_at
data_type: timestamp_tz
description: "Subscription end timestamp; null for active subscriptions"
- name: is_active
data_type: boolean
description: "Whether the subscription is currently active"
constraints:
- type: not_null
- name: created_at
data_type: timestamp_tz
constraints:
- type: not_null
- name: updated_at
data_type: timestamp_tz
constraints:
- type: not_null
Several things to notice here:
The contract.enforced: true config is the switch that activates compile-time checking. Without it, the column definitions are documentation only. With it, any mismatch fails the build.
The materialization must be table or incremental for constraints to be enforced at the warehouse level. Views don't support DDL constraints in most warehouses, and dbt will warn you if you try to apply a contract to a view. The compile-time column name and type check still works for views, but constraint enforcement (NOT NULL, PRIMARY KEY) requires a physical table.
Data types must match the warehouse dialect precisely. varchar is valid in Snowflake. In BigQuery, you'd use string. In Redshift, character varying. If there's a mismatch between what you declare and what the SQL expression produces, dbt will fail with a type conflict error. This is actually stricter than it sounds — numeric and float are different types, and timestamp versus timestamp_tz will cause a contract violation in Snowflake.
Warning
Data type matching is warehouse-specific and can be surprisingly strict. In Snowflake, NUMBER(38,0) and INTEGER are technically the same underlying storage type, but dbt may report them as different types depending on how the column is derived. Always check the actual inferred types using dbt show or by querying INFORMATION_SCHEMA.COLUMNS after your first successful build.
Let's make this concrete. Suppose a teammate modifies int_subscriptions_enriched and changes mrr_amount from a numeric to a float to accommodate some decimal precision edge case:
-- in int_subscriptions_enriched.sql
round(raw_mrr::float, 2) as mrr_amount -- changed from cast(raw_mrr as numeric(18,2))
When you run dbt run --select fct_subscriptions, you'll see something like:
Compilation Error in model fct_subscriptions (models/marts/billing/fct_subscriptions.sql)
Contract violated by column 'mrr_amount':
Expected data type: numeric(18, 2)
Got data type: float
The build fails before any SQL touches the warehouse. This is the core value proposition: the failure is loud, immediate, and actionable rather than a silent data quality degradation that gets discovered days later by a confused analyst.
This enforcement also protects against column renames. If someone renames customer_id to account_id in the upstream intermediate model and that rename flows through to fct_subscriptions, the contract catches it:
Contract violated by column 'customer_id':
Column 'customer_id' is declared in the contract but does not exist in the model output.
Available columns: subscription_id, account_id, plan_name, ...
There's an important distinction between constraints configured in the constraints: block and tests configured in the tests: block. Constraints in the contract block are DDL-level constructs that get applied to the physical table. Tests in the schema YAML are query-time checks that run during dbt test.
For a column like subscription_id declared as a primary key:
constraints:
- type: primary_key
This causes dbt to emit PRIMARY KEY (subscription_id) in the CREATE TABLE DDL. In Snowflake, primary keys are informational only — they don't prevent duplicate inserts. But they do get captured in Snowflake's INFORMATION_SCHEMA and are used by BI tools like Tableau and Looker to suggest join paths. This is valuable for data governance and semantic layer tooling.
In databases that enforce primary keys at the engine level (PostgreSQL, for example), the DDL constraint does enforce uniqueness. Know your warehouse.
Tip
Even if your warehouse doesn't enforce primary keys at the DDL level, it's worth declaring them in your contracts. Tools that read INFORMATION_SCHEMA — including data catalogs, semantic layers, and some BI tools — use this metadata to enable smarter join inference and documentation generation.
Some warehouses support custom constraints. In Snowflake, you can express custom inline constraints:
constraints:
- type: custom
expression: "mrr_amount >= 0"
name: mrr_non_negative
In Snowflake, this gets emitted as a CHECK constraint in the DDL, but (as of the time of writing) Snowflake does not enforce CHECK constraints — they're metadata only. In PostgreSQL, CHECK constraints are fully enforced. The value here is documentation and tooling integration.
dbt supports both column-level and model-level constraints. Primary keys and unique keys that span multiple columns must be declared at the model level:
models:
- name: fct_subscription_events
config:
contract:
enforced: true
materialized: table
constraints:
- type: primary_key
columns: [subscription_id, event_date]
columns:
- name: subscription_id
data_type: varchar
constraints:
- type: not_null
- name: event_date
data_type: date
constraints:
- type: not_null
This emits a composite primary key in the DDL rather than single-column key declarations.
Contracts enforce stability, but they inevitably encounter a moment where change is necessary. A column needs to be renamed. A data type needs to change to accommodate new precision requirements. A structural refactor means reorganizing the grain of a table. These are all breaking changes — changes that will silently or loudly break any consumer that was relying on the previous shape.
dbt model versioning gives you a structured way to introduce breaking changes without immediately breaking all consumers. The mental model is similar to REST API versioning: v1 keeps working for consumers who depend on it, while v2 introduces the new design and consumers migrate on their own timeline.
To version fct_subscriptions, you declare a versions: block in your schema YAML:
# models/marts/billing/schema.yml
models:
- name: fct_subscriptions
latest_version: 2
config:
contract:
enforced: true
materialized: table
versions:
- v: 1
defined_in: fct_subscriptions_v1
- v: 2
And you create the corresponding SQL files:
models/marts/billing/
├── fct_subscriptions_v1.sql # the original version
├── fct_subscriptions.sql # v2, the new default (latest)
└── schema.yml
Note
The naming convention matters. By default, dbt looks for {model_name}_v{version} as the file name for non-latest versions. You can override this with defined_in: as shown above. The latest version uses the base model name (fct_subscriptions.sql). This naming convention keeps your file system clean while making version numbers explicit.
When a downstream model calls {{ ref('fct_subscriptions') }}, dbt resolves it to the latest version by default. This is the key behavior: existing ref() calls don't break when you introduce a new version.
To pin a downstream model to a specific version, you use the version argument:
-- A downstream model pinned to v1
with subscriptions as (
select * from {{ ref('fct_subscriptions', version=1) }}
),
...
This is how you give consumers time to migrate. Their existing ref('fct_subscriptions') calls continue to work against v1 until you update latest_version: 2 in the schema YAML, at which point all unversioned ref() calls shift to v2.
Wait — that's a breaking change in itself. The transition is:
latest_version still points to 1)ref() latest_version: 2 — all unversioned ref() calls now resolve to v2This workflow is the intended migration path. Let's work through a realistic example.
Our fct_subscriptions model has a customer_id column, but after a data model restructuring (aligning with a new accounts concept), the column needs to be renamed to account_id. Downstream consumers using customer_id will break.
Step 1: Create v2 with the new schema
-- models/marts/billing/fct_subscriptions.sql (this is now v2)
with subscriptions as (
select * from {{ ref('int_subscriptions_enriched') }}
),
final as (
select
subscription_id,
account_id, -- renamed from customer_id
plan_name,
plan_tier,
mrr_amount,
started_at,
ended_at,
is_active,
created_at,
updated_at
from subscriptions
)
select * from final
-- models/marts/billing/fct_subscriptions_v1.sql (preserved for backward compat)
with subscriptions as (
select * from {{ ref('int_subscriptions_enriched') }}
),
final as (
select
subscription_id,
account_id as customer_id, -- backward compat alias
plan_name,
plan_tier,
mrr_amount,
started_at,
ended_at,
is_active,
created_at,
updated_at
from subscriptions
)
select * from final
Step 2: Update the schema YAML with both versions declared
models:
- name: fct_subscriptions
latest_version: 1 # still pointing to v1 during migration period
versions:
- v: 1
defined_in: fct_subscriptions_v1
config:
contract:
enforced: true
materialized: table
- v: 2
config:
contract:
enforced: true
materialized: table
# Shared column definitions apply to both versions
columns:
- name: subscription_id
data_type: varchar
constraints:
- type: not_null
- type: primary_key
- name: plan_name
data_type: varchar
- name: mrr_amount
data_type: numeric(18, 2)
constraints:
- type: not_null
# ... remaining shared columns
Tip
You can define columns at the model level (shared across all versions) and then override or add columns at the version level. This DRY approach is valuable when most columns are stable and only a few change between versions. Use include: and exclude: within the version block to customize which shared columns each version exposes.
Step 3: Version-level column overrides
When a column changes between versions, you need to declare it at the version level:
versions:
- v: 1
defined_in: fct_subscriptions_v1
columns:
- include: all # inherit all shared columns
exclude: [] # nothing excluded
- name: customer_id # v1-specific column name
data_type: varchar
constraints:
- type: not_null
- v: 2
columns:
- include: all
- name: account_id # v2-specific column name
data_type: varchar
constraints:
- type: not_null
This YAML structure tells dbt that v1 exposes customer_id and v2 exposes account_id, while all other columns are shared.
Step 4: Warehouse table naming
By default, dbt materializes versioned models with version suffixes in the table name:
fct_subscriptions_v1fct_subscriptions_v2When latest_version is 1, fct_subscriptions (no suffix) is an alias or synonym pointing to fct_subscriptions_v1. When you flip to latest_version: 2, the un-suffixed name points to v2.
Warning
The "latest version gets the un-suffixed name" behavior is implemented differently depending on your warehouse and dbt adapter. In Snowflake and BigQuery, dbt creates a view with the base name that points to the current latest version's physical table. When you change latest_version, the view definition is updated. Any BI tool or external system querying analytics.billing.fct_subscriptions will transparently switch to v2. But if that external system has column references hardcoded (e.g., a Looker LookML field referencing customer_id), it will break at query time. The view swap doesn't protect you from column-level incompatibility in non-dbt consumers.
dbt provides a deprecation_date property at the version level to communicate and eventually enforce version retirement:
versions:
- v: 1
defined_in: fct_subscriptions_v1
deprecation_date: "2025-03-01"
columns:
- include: all
- name: customer_id
data_type: varchar
When deprecation_date is set:
fct_subscriptions version 1 after the deprecation dateTo make deprecation warnings fail the build — which is appropriate for enforcing migration deadlines — you can configure on-run-end hooks or use the --warn-error flag:
dbt run --warn-error
This promotes all warnings to errors, including deprecation warnings. Use this in your CI pipeline to ensure no new ref() calls to deprecated versions are merged.
For tighter control, you can also use --warn-error-options in dbt 1.6+:
dbt run --warn-error-options '{"include": ["DeprecatedModel"]}'
This only promotes deprecation warnings to errors, leaving other warning types (like missing documentation) as warnings.
The real complexity emerges when multiple teams own different parts of the dbt project. If you're working with dbt Mesh across multiple projects, contracts become the formal interface between projects — analogous to service contracts in a microservices architecture.
In a dbt Mesh setup, a "public" model in one project is exposed to other projects via {{ ref('project_name', 'model_name') }}. Enabling a contract on a public model means any changes to that model's schema must go through the versioning workflow — you cannot simply rename a column in a public contracted model and deploy.
The workflow becomes:
fct_subscriptions in finance_dbt_projectmarketing_dbt_project via cross-project refcustomer_id → account_id:fct_subscriptions with account_idaccount_idlatest_version to 2 after Marketing confirms readinessThis workflow maps naturally to how data governance and lineage tracking should work in a mature platform: changes are communicated, tracked, and coordinated rather than deployed unilaterally.
Key insight
Model contracts in dbt are most powerful when combined with lineage visibility. If you know exactly which downstream models, BI reports, and Reverse ETL jobs consume a given model, you can enumerate all consumers before deciding whether a change is truly breaking. Tools like OpenLineage and Marquez — covered in Multi-Hop Data Lineage Tracking Across the Modern Data Stack — give you this visibility beyond the dbt project itself.
Your CI pipeline should validate that contracted models can't have their contracts silently weakened. With dbt Slim CI and state-aware deployments, you can scope your CI run to only modified models and their downstream dependencies:
# In your CI pipeline
dbt run --select state:modified+ --defer --state ./prod_manifest
dbt test --select state:modified+
But for contracted models, you want an additional check: ensure that a PR changing a contracted model either (a) follows the versioning protocol or (b) is a non-breaking additive change.
A practical approach is a pre-commit or CI script that parses the manifest JSON and flags any contracted model where:
enforced: false)# check_contract_violations.py - simplified example
import json
import sys
def load_manifest(path):
with open(path) as f:
return json.load(f)
def check_contracts(old_manifest, new_manifest):
violations = []
for node_id, new_node in new_manifest['nodes'].items():
if new_node.get('config', {}).get('contract', {}).get('enforced'):
old_node = old_manifest['nodes'].get(node_id)
if not old_node:
continue # new node, no contract to compare
old_columns = {c['name']: c for c in old_node.get('columns', {}).values()}
new_columns = {c['name']: c for c in new_node.get('columns', {}).values()}
# Check for removed columns
for col_name in old_columns:
if col_name not in new_columns:
violations.append(
f"BREAKING: Column '{col_name}' removed from contracted model "
f"'{new_node['name']}' without version bump"
)
# Check for type changes
for col_name, old_col in old_columns.items():
if col_name in new_columns:
new_type = new_columns[col_name].get('data_type')
old_type = old_col.get('data_type')
if new_type != old_type:
violations.append(
f"BREAKING: Column '{col_name}' in '{new_node['name']}' "
f"changed type from '{old_type}' to '{new_type}'"
)
return violations
if __name__ == '__main__':
old = load_manifest('prod_manifest/manifest.json')
new = load_manifest('target/manifest.json')
violations = check_contracts(old, new)
if violations:
print("Contract violations detected:")
for v in violations:
print(f" ❌ {v}")
sys.exit(1)
print("✅ No contract violations detected")
This script runs after dbt compile in CI, comparing the generated manifest against the production manifest. It's a lightweight gate that catches breaking changes before they reach production.
A contract that's well-enforced but poorly documented is only half useful. When downstream consumers file a Jira ticket saying "the schema changed and broke our pipeline," the first question is always "what should the schema look like and why?"
dbt's contract framework integrates with column-level documentation. Beyond data types and constraints, you should treat column descriptions as part of the formal contract:
columns:
- name: mrr_amount
data_type: numeric(18, 2)
description: >
Monthly Recurring Revenue attributed to this subscription in USD.
Calculated as the contracted monthly fee normalized from annual contracts.
Excludes one-time fees, discounts applied post-invoice, and refunds.
Source: billing.subscriptions joined with billing.contract_terms.
Updated: daily via incremental refresh at 06:00 UTC.
meta:
owner: "billing-data-team"
pii: false
sensitivity: "internal"
business_definition_url: "https://notion.so/company/mrr-definition"
constraints:
- type: not_null
The meta: block is arbitrary key-value metadata that gets stored in the manifest and surfaced in dbt docs, data catalogs, and lineage tools. Treating documentation rigor as a contract requirement — not just a nice-to-have — means consumers can trust both the structure and the semantics of what they're consuming.
Incremental models add complexity to contract enforcement because the SQL executed during an incremental run differs from the initial build. The contract check runs against the incremental query — the SELECT that produces new rows — not the full historical query.
This means if your incremental model uses conditional logic based on {{ is_incremental() }}, both the full-refresh and incremental paths must produce the same column set:
-- models/marts/billing/fct_subscription_events.sql
{{
config(
materialized='incremental',
unique_key='event_id',
contract={'enforced': true}
)
}}
with events as (
select
event_id,
subscription_id,
event_type,
event_timestamp,
metadata_json,
processed_at
from {{ ref('int_subscription_events') }}
{% if is_incremental() %}
where event_timestamp > (
select max(event_timestamp) from {{ this }}
)
{% endif %}
)
select * from events
The contract is checked against the compiled SQL without the is_incremental() block (i.e., as if it's a full refresh). This is intentional — the full-refresh path defines the canonical schema.
Warning
A common mistake is adding extra diagnostic or debugging columns to the incremental path that don't exist in the full-refresh path. Even if these columns are just metadata (like _incremental_batch_id), they'll cause contract violations because the column definitions won't include them. Either declare all columns in the contract — including incremental-only ones — or remove them from the incremental path.
For very large incremental models, contract validation on every run adds negligible overhead compared to the cost of the SQL execution itself. But if you're running incremental models at scale, be aware that contract compilation happens even for --select runs of specific models.
In this exercise, you'll take an existing mart model, add a contract, introduce a breaking change, and work through the versioning migration.
You'll need a dbt project connected to Snowflake (or another supported warehouse) running dbt Core 1.5+. If you don't have one set up, Setting Up Your First Cloud Data Warehouse will get you there.
Choose a mart-layer model in your project that is:
tableAdd contract: enforced: true to its config block.
Add explicit data_type: declarations to every column in your schema YAML. Run dbt run --select {your_model} to see the contract check pass.
Intentional break: Remove one column from the schema YAML while leaving it in the SQL. What error do you get? Is it what you expected?
Intentional type mismatch: Change a numeric column's declared data_type to varchar. Run dbt run again. What does the error say?
Create v2 of your model by duplicating the SQL file as {model_name}_v2.sql.
In v2, rename one of the columns (e.g., a foreign key column like customer_id → account_id).
Update the schema YAML to declare both versions with appropriate column overrides.
Keep latest_version: 1 and verify that {{ ref('your_model') }} still resolves to v1.
Create a simple downstream test model that explicitly references {{ ref('your_model', version=2) }} and uses the new column name.
Run dbt run --select your_model+ and verify both versions materialize.
Set deprecation_date on v1 to a date in the past (to trigger immediate warnings).
Run dbt compile with --warn-error. Observe what happens.
Update the downstream test model to remove the explicit version=2 pin and just use {{ ref('your_model') }}.
Set latest_version: 2 and run the project. Confirm the un-suffixed table now reflects v2's schema.
Symptom: Your contract is configured but column data type constraints (NOT NULL, etc.) aren't appearing in the warehouse.
Cause: Contracts require table or incremental materialization. Views don't support DDL constraints in any major cloud warehouse.
Fix: Change your materialization to table, or accept that views will only get the compile-time column name/type check without warehouse-level constraint enforcement.
Symptom: Contract violated by column 'revenue_usd': Expected numeric(18,2), got float8
Cause: SQL arithmetic in most warehouses defaults to float for division operations. amount / 100 produces a float, not a numeric.
Fix: Explicitly cast computed columns: cast(amount / 100 as numeric(18,2)) as revenue_usd
Symptom: Compilation Error: Model 'fct_subscriptions' has versions configured but no latest_version is set
Cause: When you add a versions: block to a model, you must also declare latest_version: so dbt knows which version ref('fct_subscriptions') resolves to.
Fix: Always set latest_version: when using the versions: block, even if there's only one version.
Symptom: After updating latest_version: 2, a Looker dashboard breaks with "unknown field customer_id"
Cause: External consumers (BI tools, Reverse ETL, Python scripts) that query the warehouse directly aren't protected by dbt's ref() resolution. When the un-suffixed table view flips to v2, they break.
Fix: Before flipping latest_version, audit all non-dbt consumers of the model. Use lineage tracking tools to identify them. Coordinate migration with all consumers, not just dbt downstream models.
Symptom: Contract passes in dev but fails in production
Cause: The underlying warehouse types for a column differ between dev and prod schemas — often because dev data was loaded differently or the source table schema drifted.
Fix: Ensure your CI pipeline runs against a production-representative schema, or use Slim CI with state-aware deployments to catch these discrepancies before promotion.
Symptom: Version-level column overrides seem to be ignored
Cause: When using include: all at the version level, dbt merges the version-level columns with the shared model-level columns. If a column name exists at both levels, the version-level declaration takes precedence — but only if the name matches exactly.
Fix: Double-check that version-level column names don't accidentally duplicate model-level names with slight variations. Run dbt compile and inspect the generated manifest to see the resolved column list for each version.
Model contracts and versioning represent a significant maturation in how dbt treats the interfaces between models — moving from a world where schema changes were an informal, ad-hoc concern to one where they're first-class, enforced, and versioned.
Here's what you've learned:
latest_version implicitly--warn-error flags for CI enforcement--warn-error-options closes the loop on contract governanceThe next areas to deepen your practice:
Explore dbt Mesh and cross-project contracts: The most powerful use of contracts is as formal inter-team interfaces. If you're building or migrating to a multi-project architecture, read Migrating a Monolithic dbt Project to a Multi-Project Architecture with dbt Mesh to understand how public models and contracts interact.
Pair contracts with data freshness SLAs: Structural guarantees are half the story. Automating Data Freshness SLAs covers the temporal dimension — ensuring your contracted model is not just structurally correct but actually current.
Extend contract thinking upstream: If your staging models ingest data from external systems, consider Implementing Data Contracts Between Ingestion and Transformation to apply the same discipline at the source layer.
Monitor for contract-adjacent quality issues: Contracts don't catch data quality problems — only structural ones. For runtime monitoring and anomaly detection on contracted models, Monitoring dbt Pipeline Failures in Production covers the tooling needed to close that gap.
Contracts and versioning are infrastructure-level investments. They feel like overhead until the day they save you from a 2am production incident — and then they feel like the best decision your team ever made.