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.
| Aspect | Contracts | Data tests | dbt-expectations |
|---|---|---|---|
| When it runs | Compile time | After build | After build |
| What it checks | Column names, types | Content (values, uniqueness) | Schema + content |
| Compute cost | Zero | SQL queries | SQL queries |
| Works on sources | No | Yes | Yes |
| On failure | Model doesn’t build | Model exists, test fails | Model exists, test fails |
| Coverage | Structural only | Content only | Both |
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_nullon primary keys (content guarantee, cheap SQL queries)dbt_expectations.expect_column_values_to_be_betweenon 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.