Introduction

When a dbt project is small, making changes to your models is fast and low-risk. But once multiple data teams start sharing the same datasets, and critical production dashboards depend on those tables, a single unannounced column modification can break a downstream pipeline or report without throwing an immediate compilation error.

To help keep those pipelines reliable, dbt provides two safeguards that are easy to confuse:

  • dbt tests: These check the data values inside your rows after a model runs (verifying things like unique keys, null fields, or valid categories).
  • dbt model contracts: These enforce the schema shape of the model before the SQL code ever executes (guaranteeing that specific columns exist and use the exact data types expected).

In my experience, the biggest misconception teams have when adopting contracts is treating them as an upgrade to standard data tests. They aren't. 

A contract is a versioned interface agreement between the team that produces a table and the teams that depend on it. This becomes critical in dbt Mesh where cross-project refs require predictable schemas. Like any API contract it guarantees the shape of what you publish, not the correctness of the values inside.

This article breaks down the different roles of dbt tests and model contracts, and highlights exactly where the latter provide the pre-build protection that tests can't.

Why dbt tests are not enough

Standard data tests are highly effective, but they are designed to solve a completely different problem than a changing schema. They look directly at the data values inside the model to see if they are correct, scanning row-level contents to verify that primary keys are unique, fields are not_null, or status codes fall into accepted_values.

Because these tests are designed to look at the data inside the rows, they are fundamentally reactive. If an engineer accidentally deletes a column or changes a data type, a standard test looking for valid row values can’t prevent the table from building - it allows the modified structure to deploy anyway, landing a broken schema directly into production.

Tests are post-hoc. Even a test that checks for column existence only discovers the problem AFTER the broken schema is already materialized in your warehouse.

"Standard data tests act exactly like a smoke detector: they excel at alerting you to a problem after the fire has already started. Model contracts are the structural blueprint: they make sure the room was built with fireproof materials before you ever strike a match." 

While tests protect the quality of the data inside the model, contracts step in to answer a fundamental question before a new version of the table or view is materialized in a warehouse: Does the model still keep its promised shape?

What dbt contracts do differently: Catching breaks at the blueprint stage 

A dbt model contract is a build-time guarantee that your model produces exactly the columns, data types, and constraints you have specified. Instead of letting your tables dynamically change shapes based on whatever SQL code a developer writes, a contract locks in a strict layout agreement about what your data asset looks like.

To turn this protection on, you enable the contract by setting enforced: true directly in your YAML file. Along with this flag, you explicitly declare the expected column names, required data types, and constraints for that specific model.

When combined with dbt Cloud CI using state:modified, dbt immediately fails the build inside the pull request.  The developer finds out about the mistake before any broken code is committed to the main branch, keeping the schema guaranteed so an unannounced change from one team never breaks the downstream work of another.

Quick summary: dbt tests vs. model contracts

Feature dbt Data Tests dbt Model Contracts
What it protects The content (the data values inside the rows). The interface (the column names and data types).
When it runs Reactive: Runs after the model has already been built. Proactive: During the build, before an invalid model is materialized.
Primary Goal Catches data quality issues (e.g., nulls, duplicate records). Prevents breaking structural changes (e.g., deleted or renamed columns).
Enforcement Test failure status; CI must be configured to block merges. Fails the build itself; model cannot materialize.

When to use dbt model contracts (and when to skip them)

You don't want to apply contracts to every single model in your dbt project. Instead, you should start with your most critical models and slowly apply them where necessary: 

Project Scenario / Layer Use Contracts? Strategic Reason / Impact
Initial / Experimental Phase Skip Avoid using contracts when you are constantly iterating on SQL logic, testing new joins, and shifting column requirements.
Public or Shared Models Apply Highly recommended. When you have cross-project dependencies where other teams rely on your data, these models must stay completely stable.
Critical Business Datasets Apply Essential for high-stakes models that your business depends on every day, where an unexpected change would cause immediate operational problems.
Incremental models Apply with caution Requires changing update settings to append columns or fail. Default behavior ignores structural shifts, causing table layout drift.

Pro-tip: To make this boundary airtight, pair your contracts with dbt Mesh access controls. Marking your contracted models as public guarantees other teams can safely query them, while keeping your internal transformation steps completely hidden.

Where contracts do and do not work

dbt model contracts are powerful, but they have strict boundaries. You can’ use them across your entire dbt project.

They’re only supported for standard SQL models using the following materializations:

  • Tables
  • Views
  • Incremental models

They do not work for:

  • Ephemeral models (not materialized, contract cannot apply)
  • Python models (mostly unsupported, except on major platforms like Snowflake, Databricks, or BigQuery)
  • Materialized views (not supported)
  • Sources (contracts are for models only, cannot protect upstream)

This is an important distinction to make: they are designed purely to protect your internal data transformation steps. 

Because they can’t be applied to raw upstream sources, a contract won't stop an external software team or ingestion tool from changing the raw data before it hits your warehouse. It simply catches the fallout at the first contracted boundary. 

The operational cost of contracts: The YAML maintenance tax

Whenever a growing data team rushes to turn on contract enforcement everywhere chasing "perfect reliability," they almost always end up with engineering paralysis instead. 

While contracts protect your pipeline, setting enforced: true introduces immediate maintenance overhead - what data teams call a "YAML tax." 

  • The complete schema requirement: A contract applies to the entire model, meaning your YAML definition must explicitly list every single column the SQL query outputs along with its data type. While dbt uses type aliasing to map generic types like string to your specific warehouse's format, you still have to explicitly declare that type family for every single field.
  • The compilation block: If you add a quick, harmless column to your SQL SELECT statement but forget to update the corresponding YAML file, dbt instantly kills the build before it even runs. Forcing your team to fight those errors on fast-moving staging layers or experimental models is an easy way to completely wreck your development speed.
  • The layout constraint: In many warehouses, dbt enforces this by matching the table layout to your exact YAML sequence. The real trap occurs if you change this sequence later: it can easily break fragile downstream systems that use SELECT *, forcing you to write out explicit column lists instead to keep everything safe. 

Reducing the YAML tax with automation

Don't write contracts by hand. Use the dbt-codegen package to generate them automatically.

The generate_model_yaml macro inspects an existing model and generates a YAML definition containing its columns and data types. Paste the generated output into your schema file, review it, and enable contract enforcement. Instead of manually defining dozens or hundreds of columns, you only need to verify the generated YAML before using it as your contract.

The cross-team scenario: Protecting consumers from producers

When a data team grows, the handoff between departments is usually where things break. Say one team changes a core, shared table to clean up their code, while a completely separate team has three critical executive dashboards pointing directly at that exact table. 

If Team A deletes an old column, renames a key tracking field, or changes a column from a number to text, here is how it plays out depending on your setup:

  • Live without a contract: Team A merges their code because their local SQL runs fine. Because standard dbt data tests only run after a model builds, the deployment pipeline finishes without an error. The broken structure hits production, Team B's dashboards instantly crash on the missing column, and everyone finds out only when stakeholders stare at broken charts. 
  • Live with a contract: The contract acts like an explicit API agreement on that shared table. The moment Team A alters a column or data type in their SQL file, dbt’s pre-build check compares the new code against the locked YAML definition. The build instantly fails, blocking the code from updating the production warehouse until both teams coordinate the change. 

Cross-project contracts (dbt Mesh)

When you use dbt Mesh across multiple projects, contracts become your primary line of defense. Instead of allowing different data teams to blindly pull from each other's workspaces, the producer team marks their shared model as public and locks it down with an enforced contract, while the consumer team references it directly in their own pipeline.

Team B gets a guaranteed schema. If Team A tries to break the contract, their CI fails before the change reaches production. Without contracts, cross-project refs are dangerous - Team A can accidentally break Team B without ever knowing a dependency exists.

Connecting the dots: How CI catches the break

This safety net usually starts working the moment a developer opens a pull request, which triggers a CI job to validate the change. To keep that check fast, teams configure CI to run only on the models that changed, by using dbt’s state:modified selector instead of rebuilding the entire project.

It’s a common misconception that state:modified checks your schema - it doesn't. Its only job is to look at your project's dbt artifacts (like the manifest file)  to figure out which models changed so dbt can isolate them. The contract itself is what actually checks the structure when those models run. 

The actual contract check triggers the split second dbt executes one of those isolated models. Before a single row is written to the warehouse, dbt compares your SQL query's output columns directly against your YAML blueprint. 

If the column names or data types no longer match, dbt catches the violation immediately during the preflight compile step. For any model with an enforced contract dbt fires a hard ERROR that kills the CI build on the spot. This blocks the pull request from merging, giving you full control over schema drift before it ever slides into production.

Platform enforcement: PostgreSQL vs. Snowflake

This is where theoretical documentation meets real-world execution, and where I see most teams get tripped up. A contract locks down your schema definition in dbt, but how those rules are enforced at the database level depends entirely on your model type and your data warehouse. 

Crucially, database constraints only work on table and incremental models. If your dbt model is a view, the database can’t physically enforce these rules, and a broken schema structure can still pass through to downstream queries. 

  • PostgreSQL: Strictly enforces your constraints at the storage layer. Your query executes, but the moment the database tries to write the data, it catches and blocks any values that violate your contract (like duplicate rows in a unique column).
  • Snowflake & BigQuery: Treat these constraints as purely informational metadata - they will not stop data that violates your contract from being written to the table. However, both platforms do strictly enforce not_null constraints at the database layer, physically aborting the write if a null value slips through. Specifying those informational key constraints is still highly valuable for performance. For instance, Snowflake’s query optimizer can use this metadata to eliminate unnecessary joins, but only if you explicitly append the "rely": true property to your constraint to tell the optimizer it can trust your data layout.

If your query outputs duplicate rows, Snowflake will not block the update; it will let them pass right into your table anyway.

Because Snowflake doesn’t physically stop duplicate rows at the database level, data constraints alone are not enough to guarantee data quality. You still need to run standard dbt tests to verify that those tracking columns contain distinct, unrepeated values.  

Custom constraints for governance (Snowflake)

While Snowflake treats traditional constraints as metadata, you can use custom constraints to inject security rules, like automatically applying a column-level masking policy to sensitive email fields during table creation, turning your dbt project into a single source of truth for both schema and compliance. 

Managing these platform-specific quirks is precisely why many modern data teams rely on specialized Snowflake implementation and consulting to properly pair structural contracts with runtime data tests.

WARNING: The on_schema_change trap

If you use contracts with incremental models and forget to set on_schema_change, you will get schema drift.

on_schema_change Behavior Contract Safe?
ignore (DEFAULT) New columns dropped NO
fail Build fails on schema change YES
append_new_columns New columns added to table YES
sync_all_columns Removes old columns too NO (breaking!)

Critical Rule: When using contracts on incremental models, always explicitly set on_schema_change: append_new_columns or fail.

Model versioning: The release valve for strict contracts

Contracts do not exist in a vacuum; they are part of what we call the Model Governance Triad, which balances Access (who can reference a model), Contracts (what shape it guarantees), and Versions (how to manage changes).

Model Access Model Contracts Model Versions
WHO can ref it WHAT shape it guarantees HOW to manage changes
private / protected / public columns, types, constraints v1, v2, deprecation dates

When combined, access controls prevent unauthorized dependencies, contracts guarantee schema stability for allowed consumers, and versions provide a clean migration path when schemas eventually must change. This becomes essential in a decentralized dbt Mesh architecture. 

Because dbt contracts lock your schema down, you need a clear strategy for handling updates over time:

  • Non-breaking changes: Adding a new column to the end of your query is safe. Simply update your SQL logic, add the new field to your YAML blueprint, and deploy.
  • Breaking changes: Deleting a column, renaming a key field, or changing a data type will instantly trip the contract check and block your build.

When a contract blocks a breaking change, it’s a signal that your dataset has outgrown its current layout. Instead of forcing a high-risk, immediate migration that forces every downstream team to update their dashboards at the exact same second, use dbt’s model versioning. 

This allows you to deploy the new structure right next to the old one under the same logical name, giving consumers a comfortable window to migrate their reports at their own pace without unexpected pipeline failures.

Pro-tip: Contracts only catch structural shifts. They are completely blind to semantic changes (like changing the underlying SQL logic of how a metric is calculated). 

Example: If you change the revenue column calculation from gross revenue to net revenue, but keep the column name and data type the same, the contract passes without complaint.

Only code review, documentation, and communication catch semantic drift. This is why contracts complement - but don't replace - good engineering practices.

Final thoughts

A contract guards structure and it earns its keep on the models other teams build on. 

Not every model in your dbt project needs one though, and turning on enforcement everywhere is its own kind of risk. If you’re deciding which models warrant it, we can help you scope and build it into the pipeline. Explore our dbt implementation services and let’s talk through your setup.