Datatrail
Blog / Guides 9 min read

Data Contracts in dbt: What a Model Contract Actually Enforces

Last updated August 2026 · Datatrail

Lineage map
Lineage mapped from query history. Read-only connection.
0

Read-only connection. Datatrail never moves or mutates your data.

A dbt model contract enforces the shape of a model at build time. Set contract: enforced: true, declare every column with a name and a data type, and dbt runs a preflight check before it builds: if the transformation would return different columns or different types, the build fails instead of shipping. What most teams get wrong is the constraints. On Snowflake, BigQuery, and Redshift, only not_null is actually enforced. Declared primary keys, foreign keys, and unique constraints are metadata that the warehouse records and ignores.

That gap between what you can declare and what is enforced is the single most useful thing to understand about dbt contracts, because it decides how much protection you are really buying. Here is what dbt does, exactly where it stops, and what to put behind it.

What does a dbt model contract enforce?

It enforces that the dataset your model returns matches the shape you declared. dbt's own documentation puts it plainly: while building your model, dbt will verify that your model's transformation will produce a dataset matching up with its contract, or it will fail to build. The check runs as a preflight step before the build, so a mismatched model never lands in the warehouse at all.

Three things are in scope: column names, column data types, and any constraints your platform supports. Everything else about the data is out of scope. A contracted model can return zero rows, wildly wrong numbers, or data that is four days stale, and the contract will pass without complaint every time.

The build-time framing matters. This is a check on your transformation code, not on your data. It protects consumers from you accidentally renaming customer_id to cust_id. It does nothing about the upstream source system that started sending nulls this morning.

How to add a data contract to a dbt model

Contracts go in the model's YAML. You need contract: enforced: true under config, plus a name and a data_type for every column.

models:
  - name: fct_orders
    config:
      contract:
        enforced: true
    columns:
      - name: order_id
        data_type: varchar
        constraints:
          - type: not_null
      - name: customer_id
        data_type: varchar
        constraints:
          - type: not_null
      - name: order_total_usd
        data_type: number(18,2)
      - name: placed_at
        data_type: timestamp_ntz
        constraints:
          - type: not_null
      - name: status
        data_type: varchar

The requirement people trip over is that this is all or nothing per model. dbt applies contracts to all columns in a model and requires explicit expectations for all of them, not just the ones you care about. If your model returns forty columns and you declare five, the build fails. That is a deliberate design choice and a defensible one, since a contract that silently ignores thirty-five columns is not much of a contract, but it means adding one to a wide model is a real chunk of typing.

Two other limits worth knowing before you start. Contracts work on SQL models materialized as table, view, or incremental. They are not supported on Python models, materialized views, ephemeral models, or models using recursive CTEs on BigQuery. And data_type is the warehouse's type, not a dbt abstraction, so a project that targets more than one platform will need per-target handling.

Which constraints does dbt actually enforce?

This is the table to read twice. dbt sorts constraints into three buckets: definable and enforced, where the model will not build if violated; definable but not enforced, where the constraint is recorded as metadata only; and not definable at all. Which bucket a constraint lands in depends entirely on the warehouse, not on dbt.

ConstraintSnowflakeBigQueryRedshiftDatabricksPostgres
not_nullEnforcedEnforcedEnforcedEnforced, after buildEnforced
primary_keyMetadata onlyMetadata onlyMetadata onlyMetadata onlyEnforced
foreign_keyMetadata onlyMetadata onlyMetadata onlyMetadata onlyEnforced
uniqueMetadata onlyNot definableMetadata onlyNot definableEnforced
checkNot definableNot definableNot definableEnforcedEnforced

Read across the Snowflake column and the picture is stark. Of five constraint types, one is enforced, three are recorded and ignored, and one cannot be expressed. BigQuery is worse: you cannot even declare uniqueness. Postgres is the only platform in the list where the constraint system behaves the way most engineers assume it does, which makes sense, since it is the only one in the list that is a transactional database rather than an analytical warehouse.

Databricks and Spark carry an extra wrinkle: not_null and check constraints are enforced only after a model is built, not as a preflight. The bad rows land, the constraint then rejects them, and you deal with the aftermath rather than preventing it.

Why does the warehouse ignore a primary key?

Because enforcing uniqueness on write means checking every incoming row against every existing one, and analytical warehouses are built to load billions of rows fast. They trade that guarantee away deliberately. Snowflake's own constraints documentation states that on standard tables, PRIMARY KEY, UNIQUE, and FOREIGN KEY constraints are optional and not enforced, and only NOT NULL is enforced. Hybrid tables are the exception, where primary and foreign keys are both required and enforced, which is the giveaway: enforcement is a transactional feature, and it lives with the transactional table type.

So a declared primary key on a Snowflake table is a hint the query optimizer may use and a piece of documentation for humans and catalog tools. Duplicate rows will land in a column marked as a primary key, and nothing anywhere will raise an error.

The practical consequence for a dbt project is that a contract and a test are doing different jobs and you need both. constraints: - type: primary_key documents intent. A unique generic test in the same YAML is what actually catches duplicates, by running a SQL query after the build and failing if it returns rows. Teams who move their uniqueness expectations from tests into contracts, assuming they have upgraded, have quietly turned the check off.

What dbt contracts do not cover

Four things, in rough order of how often each one causes a real incident.

  1. Anything about the values. Row counts, freshness, distributions, nulls in columns you did not mark. A contracted model that returns three rows instead of three million builds cleanly and passes.
  2. Upstream sources. Contracts sit on your models. The tables your models read from are not contracted, so a source that changes a type or starts arriving late is not caught here. Source freshness and tests cover part of that.
  3. Semantics. If the checkout service changes how it calculates a discount, order_total_usd keeps its name and its type and becomes quietly wrong. This is the failure that costs the most and the one no structural check will ever find.
  4. The consumers. A contract can stop a breaking change from building. It cannot tell you that fourteen dashboards and a reverse ETL sync read the column you were about to alter, which is the information you needed to decide whether the change was worth making.

That last one is why dbt contracts and lineage are complements rather than alternatives. The contract is the gate; lineage is what tells you what is on the other side of it. Our guide to schema change impact analysis covers how to work that out before a pull request, and dbt lineage explains how the graph gets built from the manifest and query history.

dbt model contracts vs the Open Data Contract Standard

These solve overlapping problems at different scopes and they are frequently confused, partly because both are YAML and both use the word contract.

dbt model contractOpen Data Contract Standard
ScopeOne dbt model in one projectAny dataset on any platform
CoversColumn names, types, constraintsSchema plus ownership, quality rules, SLA, support channel, servers
Enforced bydbt, at build timeWhatever tool reads it, commonly the Data Contract CLI
Readable by other toolsOnly dbtAny ODCS-aware tool, and it exports to 25+ formats
Versiondbt-core 1.12.0, July 2026ODCS v3.1.0, December 2025

The important structural difference is the audience. A dbt contract is an agreement between you and your future self, enforced inside your own repository. An ODCS contract is an agreement between two teams, which is why it has fields for who owns the data, how to escalate, and what the service level is, and a dbt contract does not.

One more thing worth flagging if you are researching this now: as of December 2025 there is only one open standard worth adopting. The competing Data Contract Specification deprecated itself in favour of ODCS, with its README stating that with the release of ODCS v3.1.0 it is deprecated "in line with our commitment to focus on a single industry standard for data contracts", and support in the Data Contract CLI continuing only until the end of 2026. Most articles comparing the two as live options have not been updated. We wrote up the whole landscape in our guide to data contracts.

How to use both together

The combination that works in practice, in the order teams usually get to it.

Write the ODCS contract for the boundary, not for every model. The datasets that cross a team line get an ODCS file with an owner, an SLA, and a support channel. That is usually three to ten datasets, not three hundred. Everything internal to your dbt project does not need one.

Generate the dbt YAML from it. The Data Contract CLI exports to dbt-models and dbt-sources, so the schema block in your ODCS contract becomes your dbt column declarations rather than being retyped and drifting. Version 1.1.0 landed on PyPI in August 2026 and supports ODCS natively.

Turn on enforcement for the models that back a contracted dataset. Now the ODCS promise about structure has teeth, because a change that breaks it fails the build.

Keep your tests. Uniqueness, referential integrity, accepted values, and freshness all still need generic or singular tests, because the constraint matrix above shows the warehouse will not enforce most of them. If you are weighing test frameworks, we compared Great Expectations and dbt tests on where each one can run.

Accept that some producers cannot be contracted at all. When the upstream system is a vendor SaaS or an external API, there is nobody on the other side to agree to anything or to fail a build. A published third-party feed, whether that is a payments API or an on-chain data API, gives you a documented schema and no commitment about tomorrow. All you can do there is monitor and be told quickly, which is a different tool from a contract.

How do you know when a contract breaks in production?

You do not, from dbt alone, and this is the honest limit of build-time enforcement. A failed build tells you a developer tried to break the contract. It says nothing about the far more common case: nobody changed any code, and the data changed anyway.

For that you need something watching the tables themselves rather than the transformation that produces them. The checks that matter are freshness (did it land when the SLA said it would), volume (is the row count in its normal range), null rate on the columns your contract marked as required, and schema drift on the sources upstream of your models. dbt source freshness covers the first of those; the rest need continuous monitoring.

What turns that from an alert into something actionable is knowing who is affected. This is where data contracts and lineage meet: the contract records what consumers were promised, and a column-level lineage graph is what tells you which of them just stopped getting it. Datatrail connects to Snowflake, BigQuery, Redshift, Databricks, or Postgres with a read-only role, builds that graph from query history and your dbt manifest, learns each table's normal behavior, and delivers every breach with the models, exposures, and dashboards downstream of the affected column already attached. Compute stays in your warehouse and no rows are copied out.

The short version

Turn contracts on for the models other teams depend on. Expect them to catch renames and type changes and nothing else. Do not move your uniqueness or referential checks out of tests and into constraints, because on every cloud warehouse except Postgres those constraints are documentation. Write the cross-team agreements in ODCS rather than in dbt YAML, since that is where ownership and SLAs live. And put monitoring behind all of it, because the contract governs the code and most of what goes wrong happens to the data.

See how your data flows, end to end

Connect your warehouse read-only and map lineage, freshness, and downstream impact before a change breaks a dashboard. Planned transparent pricing, no card to start.