ServicesAboutNotesContact Get in touch →
EN FR
Note

dbt Validation Mechanisms Compared

How dbt contracts, data tests, and dbt-expectations differ in when they fire, what they cover, and what they cost — and why you need all three.

Planted
dbtdata qualitytesting

dbt provides three distinct validation mechanisms: contracts, data tests, and dbt-expectations. They fire at different points in the build lifecycle, catch different categories of problems, and carry different compute costs.

The Three Mechanisms

Contracts run at compile time. Before any SQL hits your warehouse, dbt compares the columns your model query would return against what you’ve declared in YAML. A mismatch in column names or data types triggers a compilation error. The model never materializes. The compute cost is zero — this is a metadata comparison, not a warehouse query. The failure mode is: the model doesn’t exist yet.

Data tests run after the model builds. They execute SQL queries against the actual table to check content: uniqueness, null rates, referential integrity, value ranges. A unique test on customer_id queries the built table and fails if duplicates exist. The model already exists when tests run — which means downstream models may have already consumed it. The failure mode is: the table is there, bad data is in it.

dbt-expectations is a community package providing 50+ tests inspired by Great Expectations. Like data tests, it runs post-build with real SQL queries — but it spans both schema-level and content-level validation. expect_table_columns_to_match_set checks that a table contains exactly the columns you specify. expect_column_values_to_be_between validates value ranges. And critically: it works on sources, which contracts can’t touch.

AspectContractsData testsdbt-expectations
When it runsCompile timeAfter buildAfter build
What it checksColumn names, typesContent (values, uniqueness)Schema + content
Compute costZeroSQL queriesSQL queries
Works on sourcesNoYesYes
On failureModel doesn’t buildModel exists, test failsModel exists, test fails
CoverageStructural onlyContent onlyBoth

Why Each Exists

The failure modes point to why you genuinely need all three rather than doubling down on one.

A contracted model with no tests guarantees shape but can deliver garbage values. Every column is the right type, the right name — but if your status column now contains values you’ve never seen before, the contract is silent. The table builds. The garbage reaches your dashboards.

A well-tested model without a contract can silently gain or lose columns when someone refactors the SQL. Tests run after the build, so the new schema is already materialized by the time tests would catch the problem. Downstream models may have already selected from it. Contracts prevent this category of breakage entirely — the refactored model never builds.

dbt-expectations bridges both. It adds the schema-level checks that data tests can’t express (does this table have exactly these columns? is the column count what we expect?) and it reaches sources, where contracts are explicitly out of scope.

Compile-Time vs. Post-Build

The most important axis is timing. Contracts are the only mechanism that prevents a bad state from reaching your warehouse. Everything else is detection after the fact.

This has real implications. When a test fails, the table already exists. If you’re running dbt build on a DAG, downstream models may have already run using the bad version of the upstream. Your CI job catches the failure and blocks the merge, but the compute was already spent and the downstream models are now in an unknown state.

When a contract fails, the build stops immediately. Nothing downstream runs. No compute is wasted on models that would have bad inputs. The error message is specific enough to act on immediately:

Compilation Error in model mrt__analytics__customers
This model has an enforced contract that failed.
| column_name | definition_type | contract_type | mismatch_reason |
| ------------ | --------------- | ------------- | ------------------ |
| customer__id | TEXT | INT | data type mismatch |

The tradeoff is coverage. Contracts only validate shape — you can’t express a range check or a uniqueness assertion in a contract. For content validation, you’re back to post-build tests.

The Practical Split

The right mental model: contracts prevent structural breaks, data tests enforce content rules, dbt-expectations covers the gaps in both directions.

For a mart model that serves downstream consumers, you want:

  • Contract enforced on every column and type (structural guarantee, zero compute)
  • unique + not_null on primary keys (content guarantee, cheap SQL queries)
  • dbt_expectations.expect_column_values_to_be_between on key metrics (business rule guarantee, cheap SQL queries)

For a source feeding that mart, contracts don’t apply. Use dbt-expectations on sources to approximate the structural guarantee contracts give you on models.

In CI/CD, this layering means failures surface at different points. A contract violation stops the build immediately with a compile error. A test failure surfaces after the build, tagged to the specific test query. You want both because they’re catching different categories of problems, not the same problem with different tools.

Where to Place What

The dbt Testing Taxonomy covers this in detail, but the principle for these three mechanisms:

Contracts: mart models that serve consumers, especially access: public models in a dbt Mesh setup. Not useful on base or intermediate models — those layers are internal, and the overhead of full column declarations on 50 intermediate models isn’t justified.

Data tests: everywhere, scaled by how much downstream consumers depend on the model. Every primary key needs unique + not_null. Critical marts need referential integrity checks. Sources need freshness checks.

dbt-expectations: sources (for schema checks that approximate contracts) and marts (for business rule enforcement). The most value per test is at the edges of the DAG — sources where problems enter and marts where consumers see them.

The full three-layer quality model (contracts, tests, anomaly detection) treats all of these as the reactive validation layer. Elementary sits in the third layer, catching anomalies that neither contracts nor explicit tests anticipated.