The Promise You Are Already Making
dbt checks column name and data_type against what the model’s SELECT actually returns, and fails the build if they disagree. That is the entire mechanism, stated completely — a contract is structural, not semantic. It has never seen a single row of data and cannot tell a dollar from a cent, a correct join from a duplicated one, or a meaningful column from a decorative one.
Chapter 12 closed on dim_subscription: a mart, therefore a public interface — whether or not you said so. Nothing enforced that promise. A column could vanish from the SELECT tomorrow and nothing in dbt would object.
Six changes to dim_subscription are queued below, one at a time. Before you look at any of them, predict what dbt build does — pass or fail — and how many rows land in the mismatch table. Commit first; nothing reveals until you do.
select
...
amount_usd,
...select
...
amount_usd / 12 as mrr_usd, -- monthly-normalised
...# models/marts/_marts__models.yml — the contract, unchanged across every scenario
columns:
- name: subscription_version_key
data_type: varchar
- name: subscription_id
data_type: varchar
- name: listener_id
data_type: varchar
- name: plan_code
data_type: varchar
- name: amount_usd
data_type: number(10,2)
- name: status
data_type: varchar
- name: valid_from
data_type: timestamp_ntz
- name: valid_to
data_type: timestamp_ntz
- name: captured_from
data_type: timestamp_ntz
- name: is_current
data_type: booleandbt checks column name and data_type against what the model’s SELECT actually returns, and fails the build if they disagree. That is the entire mechanism, stated completely — a contract is structural, not semantic. It has never seen a single row of data and cannot tell a dollar from a cent, a correct join from a duplicated one, or a meaningful column from a decorative one.
dim_subscription has exactly one consumer dbt can see — fct_subscription_revenue, reached through ref() — and it reads only plan_code, valid_from and valid_to. Every column this chapter is about to remove or rename is read by someone dbt cannot see at all: finance’s BI query, hitting the relation directly, reached only through Chapter 11’s finance_revenue_extract exposure as a name, never as a ref() edge. That is the whole argument for this chapter in one sentence: a contract protects the consumer dbt can see, and the break this chapter is built around lands squarely on the consumer dbt cannot see. Access levels, in Section 6, are about ref() too — not about who holds warehouse credentials — and that same gap runs through the entire chapter.
- contract
- A promise, declared in YAML, that this model’s output has exactly these column names and data types — checked every time the model builds. Placement: config: contract: {enforced: true} in the properties file, in-model {{ config(...) }}, or +contract: {enforced: true} in dbt_project.yml.
- additive is safe
- Adding a column is never a breaking change under a contract — it is the one explicitly blessed path. Removing, renaming or retyping a column is the only thing the check calls breaking, and a rename is really remove-plus-add, which is why it produces two mismatch rows, not one.
- structural, not semantic
- The check compares shape: name and declared type, nothing else. It ignores precision and scale outright, and it has no concept of what a column means, only what it is called and how it is typed.
- the consumer dbt cannot see
- A dbt project only knows about consumers reached through ref(). Anyone querying the built relation directly — a BI tool, a saved query, a spreadsheet connection — is invisible to the whole mechanism, and is exactly who a contract most needs to protect.
| Materialization | Columns checked? | Constraints applied? | Notes |
|---|---|---|---|
| table | yes | yes | The complete picture — what every example in this chapter assumes. |
| view | yes | no — silently absent | The contract half-works: a name or type mismatch still fails the build, but not_null, primary_key, unique and check never reach the DDL at all, and nothing tells you. |
| incremental | yes | yes, at create | Requires on_schema_change: append_new_columns or fail — ignore and sync_all_columns are rejected outright the moment a contract is enforced. |
| ephemeral | rejected outright | rejected outright | dbt refuses to compile. An ephemeral model has no relation for a name, a type or a constraint to attach to. |
| materialized view | rejected outright | rejected outright | Not supported — a different mechanical reason from ephemeral, the same outcome. |
| Python models | rejected outright | rejected outright | Contracts are a SQL-model YAML feature. |
Read the view row twice. A contracted view still fails the build on a renamed column — but ship it with not_null and primary_key declared, and every one of them is quietly absent from the DDL. The contract looks identical in the YAML; only the materialization decides whether half of it exists.
Assuming a contract on a view enforces its constraints the same way a table does. Columns are still checked — the build still fails on a name or type mismatch — but every not_null, primary_key, unique and check declared on that view is silently absent from the DDL. Nothing warns you; the constraints simply never existed on the object.
Treating a green dbt build as proof the data underneath it is still correct. The widget above ships a change that turns every dollar into a cent — same column name, same declared type — and the build stays green while every downstream sum is now 100x wrong. A contract is a promise about shape, never about meaning.
Before writing contract: enforced: true, check the materialization first — ephemeral and materialized views reject it outright, and a view keeps only half the promise.
When you catch yourself describing a contract as “it stops breaking changes,” say the second half out loud: it stops breaking changes it can see — which is only the ones expressed as ref().
Read a contract failure’s mismatch_reason column before touching anything. missing in definition and missing in contract point in opposite directions, and fixing the wrong side just moves the error to a different column.
This chapter runs at 2026-07-31 09:00 UTC, four days after Chapter 12’s handoff. dim_subscription still ships the 10 columns Chapter 12 built — subscription_version_key, subscription_id, listener_id, plan_code, amount_usd, status, valid_from, valid_to, captured_from, is_current — 10 rows, 6 with is_current = true. The graph opens this chapter at 23 nodes, 20 non-exposure, 15 buildable, 24 manifest nodes, and closes it back at exactly the same four numbers — Section 5’s migration adds nothing permanent.
This is not a warning you can silence — dbt refuses to compile the model at all. If a model genuinely needs contract enforcement, that fact alone should end the “should this be ephemeral?” conversation Chapter 12 taught you to have.
The weak answer is “it stops breaking changes.” The strong answer names the mechanism precisely: it compares the column names and data types declared in YAML against what the model’s compiled SELECT returns, at build time, for every materialization that supports it. Then give the edge that separates a mid-level answer from a senior one: it is silent about values, about grain, about joins — a currency error with an unchanged column name and type sails through every time.
A contract is a structural promise — name and type, checked at build time — and its blind spot is exactly the consumer dbt was never told about.
