What you will be able to do
- Decide which models need a contract, and when adding one is too early
- Enforce a contract from dbt_project.yml, a properties YAML file or a SQL config block, declaring every column's name and data_type
- Predict which differences dbt's preflight check catches: names, types and column count, but not column order or precision
- Identify the materializations and incremental settings that support contracts
- Tell a contract apart from a data test, and recognise the changes dbt treats as breaking
Key concept
Model contract — A model contract is a set of guarantees about the shape of a model's output: every column's name and data type, plus optional constraints. When the contract is enforced, dbt will not build the model if its SQL produces anything else.
1.Why a model needs a contract
A dbt model's columns are whatever its final select happens to return. That works well while you are iterating. Once other people and processes query the model, though, a renamed column or a changed type breaks their work. A model contract fixes the shape of the output in advance: column names, data types and, optionally, constraints. dbt checks during the build that the transformation still produces that shape. If it doesn't, the model fails to build.
Shape means more than names. A column can keep its name while its type changes from boolean (true/false) to integer (0/1), and downstream queries can then fail in surprising ways. An enforced contract pins down both the name and the type of every column.
Contracts are meant for "public" models that something downstream relies on. Inside dbt, that means models shared with other groups, other teams or other dbt projects. Outside dbt, it means the reports, dashboards and processes that expect a predictable structure, which you may already describe with exposures. A staging model that only its author touches gains little from one.
Adding governance too early has a cost. Contracts, like model access and versions, add structure, and that structure makes later changes and rollbacks harder. On a model whose columns still change every week, a contract mostly adds maintenance. Contracts also apply only to models. Snapshots, seeds and sources change structure over time, so they can't carry one.
Checkpoint 1 of 8· Check yourself
A team is deciding where to add its first enforced contract. Which resource is the best candidate?
Contracts are recommended for public models that others depend on. A model that is still changing would only gain maintenance, and snapshots and seeds can't carry contracts at all.
“We recommend defining contracts for "public" models that are being relied on downstream.”Source: docs.getdbt.com
2.Enforcing a contract in YAML, project config or SQL
A contract is switched on by setting enforced: true under the contract config. Like other model configs, it can be set in three places:
- dbt_project.yml, with +contract on a folder or project, to enforce contracts consistently across many models
- a properties YAML file, under a single model's config
- a config() block at the top of the model's SQL file, for fine-grained control over one model
Turning enforcement on is the easy part; the YAML has to declare the shape. Every column the model returns needs an entry with a name and a data_type that your data platform understands. You can't write a partial contract that covers only the important columns. The contract applies to all of them, and requiring an explicit declaration is intentional. On table and incremental models, a column can also list constraints such as not_null. What each platform actually enforces is covered under [platform-level constraints](lesson:2.3).
models:
- name: dim_customers
config:
contract:
enforced: true
columns:
- name: customer_id
data_type: int
constraints:
- type: not_null
- name: customer_name
data_type: string{{ config(
contract = { "enforced": true } -- Enables contract enforcement for this model
) }}
select
column_1_id, -- replace with your column
column_2_created_at, -- replace with your column
column_3_status -- replace with your column
from {{ source('property_management', 'rental_applications') }} -- replace with your source name and tableThe SQL block only turns enforcement on. The column names and types still come from the model's YAML entry. The same applies to the project-level setting: +contract in dbt_project.yml tells every model in that folder to enforce a contract, and each of those models still needs its columns declared.
Checkpoint 2 of 8· Fill the gap
Which config key enforces contracts on every model in the property_management project?
models:
property_management: # replace with your dbt project name
? :
enforced: trueIn dbt_project.yml, the contract config gets the + prefix and takes enforced: true. Constraints are declared on columns or models, not switched on project-wide.
Source: docs.getdbt.comCheckpoint 3 of 8· Exam question
A model's properties file reads: ```yaml models: - name: dim_products config: contract: enforced: true columns: - name: product_id data_type: int - name: product_name data_type: string - name: category ``` Running `dbt build --select dim_products` raises an error before the model ever compiles. Which line is causing the failure?
Correct answer: A — The `category` column entry, because an enforced contract requires every declared column to include a `data_type` key, and none is present here.
- A. Correct. Every column in an enforced contract must specify both `name` and `data_type`; the `category` entry only has a `name`, which dbt rejects at parse time before it ever tries to compile the model's SQL.
- B. Incorrect. Nesting `contract: enforced: true` under `config` is the documented, valid location for this setting, so this option describes correct syntax as if it were the bug.
- C. Incorrect. There is no restriction on the column name `product_id`, and contracts do not require every column to carry a constraint — constraints are optional additions on top of the required `name`/`data_type` pair.
- D. Incorrect. Columns are declared inline in the model's own properties file under `columns:`; dbt has no separate `constraints.yml` file convention for contract declarations.
3.What dbt checks when a contracted model builds
Enforcement changes two things about a build. First, dbt runs a preflight check: before anything is materialized, it compares the names and data types the query will return against the ones in the contract. Second, dbt writes the column names, data types and constraints into the DDL it sends to the platform. That DDL lists the columns in the contract's order, not the order the SQL selects them in.
Column order is never a reason for a contract failure. A missing column, an extra column, a renamed column or a wrong type is. Take a model that selects 'abc123' as customer_id while the contract declares customer_id as int. The run stops before the table exists, and the error lists each mismatched column with the reason:
20:53:45 Compilation Error in model dim_customers (models/dim_customers.sql)
20:53:45 This model has an enforced contract that failed.
20:53:45 Please ensure the name, data_type, and number of columns in your contract match the columns in your model's definition.
20:53:45
20:53:45 | column_name | definition_type | contract_type | mismatch_reason |
20:53:45 | ----------- | --------------- | ------------- | ------------------ |
20:53:45 | customer_id | TEXT | INT | data type mismatch |The type comparison is deliberately coarse. dbt aliases common type names, so string in a contract becomes text on Postgres and Redshift. A name dbt doesn't recognise is passed to the platform unchanged. To turn aliasing off, set alias_types: false under contract. dbt also ignores size, precision and scale, so varchar(256) against varchar(257) passes. If downstream consumers really depend on precision, a custom test can check it.
Ignoring precision has one catch. If you write a bare numeric, the platform applies its default precision and scale. A default scale of 0 stores only whole numbers, and that implicit coercion can make the contract fail. Declare a nonzero scale, such as numeric(38, 6). From dbt 1.7, dbt warns when a numeric type is declared without precision and scale.
Checkpoint 4 of 8· Check yourself
A contract declares a column as varchar(256). After a refactor, the model returns that column as varchar(257). What does the preflight check do?
dbt compares the type itself, not its size. A more precise assertion needs a custom test.
“When dbt compares data types, it will not compare granular details such as size, precision, or scale.”Source: docs.getdbt.com
4.Which models can carry a contract
Whether a contract works depends on the model's language and materialization. Contracts are for SQL models. A view can have one, but only for column names and data types: constraints are never applied to views. Table and incremental models get the full contract, including constraints, and incremental models have one more condition.
| Model | Contract support |
|---|---|
| table | Supported: column names, data types and constraints |
| view | Column names and data types only, no constraints |
| incremental | Supported only with on_schema_change: append_new_columns or on_schema_change: fail |
| ephemeral or materialized view | Not supported |
| Python models | Not supported |
| Custom materializations | Not supported unless added by the author |
| BigQuery models with recursive CTEs | Not supported |
| sources, seeds, snapshots | Not supported |
The on_schema_change rule exists because an incremental table can drift away from its contract without dbt noticing. Suppose you add a column to both the SQL and the YAML, but on_schema_change is unset or ignore. dbt never adds the column to the existing table, yet the merge still succeeds, because it only writes the destination columns that already exist. The contract now describes a column the table doesn't have. append_new_columns adds the new column, and fail stops the run, so either keeps the table and the contract consistent. sync_all_columns is excluded too: it also drops columns that are missing from the query, and removing a column is a breaking change for a contracted model.
Checkpoint 5 of 8· Match them up
Match each on_schema_change setting to its effect on a contracted incremental model
Tap a term, then the definition that fits it.
Only append_new_columns and fail keep an incremental table consistent with its contract. ignore lets the table fall behind the YAML, and sync_all_columns can delete contracted columns.
“Because removing existing columns is a breaking change for contracted models!”Source: docs.getdbt.com
Checkpoint 6 of 8· Exam question
A platform team is building `fct_shipments` as a governed, publicly referenced model with an enforced contract that includes `not_null` and `primary_key` constraints on `shipment_id`. Which of the following configurations will let the contract, constraints included, actually take effect during the build? (Select all that apply)(Select 2)
Correct answers: A, B — Materializing `fct_shipments` as a `table`, since a full table build lets dbt render every declared column and constraint into a single `CREATE TABLE` statement.; Materializing `fct_shipments` as an `incremental` model with `on_schema_change` set to `append_new_columns`, since that keeps the built table's columns aligned with the declared contract across repeated runs.
- A. Correct. A `table` materialization supports the full contract, including the constraints block, because dbt issues a single `CREATE TABLE` statement that can carry both the column definitions and the constraint clauses.
- B. Correct. Incremental models can carry an enforced contract with constraints, but only when `on_schema_change` is set to `append_new_columns` or `fail`, since either setting keeps the table's actual shape from silently drifting away from what the contract declares.
- C. Incorrect. A view compiles to a `CREATE VIEW` statement, not a table, so it can only carry the column name and data type portion of a contract; the constraints block is rejected because there is no underlying table to attach constraints to.
- D. Incorrect. Ephemeral models compile into a CTE embedded in downstream queries rather than building their own persisted relation, so dbt has nothing to validate a contract against and enforced contracts are not supported on them.
- E. Incorrect. The default `ignore` setting for `on_schema_change` lets the incremental table's real columns drift away from the model's SQL over time, which is the opposite of protection, so contracted incremental models must move off that default.
- F. Incorrect. Materialized views are not one of the materializations that support enforced contracts; they follow the same column-only limitation as ordinary views and cannot carry a constraints block.
5.Contracts versus data tests, and what counts as a breaking change
A contract governs shape, and data tests check content. A contract stops the build when the columns or types are wrong. A data test runs against the model after it is built, so it can check anything you can write as a query. Data tests also support settings such as custom severity thresholds, and their failures are easier to debug: you can query the built model or store the failing rows in the warehouse. The test types themselves are covered in [dbt data tests](lesson:5.1).
Sometimes a constraint can replace a test. The check then happens at build time, often for less compute. Three conditions must all hold:
- the platform supports and enforces the constraint you need, and most platforms enforce only not_null
- the model is materialized as table or incremental, not view or ephemeral
- the model has a full contract, with a name and data_type for every column
Tests stay outside the contract for the same reason uptime isn't part of a software API's contract: the contract is the structure of the response. Changing that structure in a backwards-incompatible way is a breaking change. When dbt compares the project to a previous [project state](lesson:7.1), it reports a contract error for each of these breaking changes:
- removing an existing column - changing the data_type of an existing column - removing or modifying a constraint on an existing column (dbt v1.6 or higher) - deleting, renaming or disabling a contracted model (dbt v1.9 or higher)
The last change is not treated the same for every model. On a versioned model it raises an error, and on an unversioned model it raises only a warning. When a breaking change can't be avoided, [model versions](lesson:2.2) give consumers a migration window instead of an overnight break.
Checkpoint 7 of 8· Check yourself
On dbt 1.9, a developer deletes an unversioned model with an enforced contract, and dbt compares against the production state. What happens?
Removing a contracted model counts as a breaking change from v1.9. For unversioned models dbt only warns, and the error is reserved for versioned models.
“versioned models will raise an error. unversioned models will raise a warning.”Source: docs.getdbt.com
Checkpoint 8 of 8· Exam question
The `dim_customer` model is queried directly by six downstream reporting tools and is about to receive its first enforced contract. Which action should the team take first, before flipping `contract.enforced` to `true`?
Correct answer: A — Run the model's existing SQL and record the exact column names and data types it currently returns, then write that list into the model's `columns` property so the contract matches reality on day one.
- A. Correct. Because the contract preflight check fails the build the moment the SQL's actual columns disagree with the declared contract, capturing the real, current shape first is what lets the first contracted build pass without surprises.
- B. Incorrect. Data tests validate row-level content after a model builds, while contracts validate column shape before it builds; they check different things and running both together does not raise a duplicate-check error.
- C. Incorrect. Enforced contracts and model versions are independent governance features; a model can adopt an enforced contract without ever being versioned, so no `_v1` suffix is required.
- D. Incorrect. Ephemeral models never build their own persisted relation, so dbt cannot run the contract's preflight check against them at all, making this the one materialization that cannot be used to test enforcement.
Sources1
Exam traps
Each one states something that sounds right. Open it to see what is actually true.
1.A contracted view enforces its not_null and other constraints, just as a table does.Why is that wrong?
On a view, a contract enforces only column names and data types. Constraints apply only to table and incremental models.
Covered in Which models can carry a contract
2.A contracted incremental model should use on_schema_change: sync_all_columns so the table always matches the YAML.Why is that wrong?
Contracts require append_new_columns or fail. sync_all_columns can drop existing columns, which is a breaking change.
Covered in Which models can carry a contract
3.A contract fails the build when the SQL selects columns in a different order from the YAML.Why is that wrong?
The preflight check ignores column order, and the generated DDL orders columns as the contract lists them.
4.Because dbt ignores precision, declaring a bare numeric in a contract is always safe.Why is that wrong?
A bare numeric takes the platform's default scale, which may be 0. That implicit coercion can make enforcement fail, so declare a nonzero scale.
Practise it for real
Watch an enforced contract stop a build on a type mismatch, then fix it
1.In a properties YAML file, give dim_customers materialized: table and contract: enforced: true, then declare customer_id with data_type: int and customer_name with data_type: string.
Why: An enforced contract needs a name and data_type for every column the model returns.
You should see: The project parses, and dim_customers now has a two-column contract.
2.Write models/dim_customers.sql as: select 'abc123' as customer_id, 'My Best Customer' as customer_name
Why: customer_id now returns text while the contract declares an integer.
You should see: The SQL compiles. It doesn't match the contract yet.
3.Run dbt run --select dim_customers.
Why: The preflight check compares the query's columns against the contract before anything is materialized.
You should see: A Compilation Error saying 'This model has an enforced contract that failed', with customer_id listed as TEXT against INT and a data type mismatch. No table is created.
4.Make customer_id return an integer value in the SQL, then run the model again.
Why: Names, data types and the number of columns must all match the contract.
You should see: dim_customers builds as a table, with its columns in the order the contract lists them.
Stuck? Get a nudge
If the error lists a column as missing rather than mismatched, compare the column names in the SQL and the YAML for typos.
Sources
Every claim above is drawn from one of these pages, quoted as it was written on the date shown.
- 1.
“We recommend defining contracts for "public" models that are being relied on downstream.”
↩︎ Why a model needs a contract“When enforced, your contract must include every column's name and data_type (where data_type matches one that your data platform understands).”
↩︎ Enforcing a contract in YAML, project config or SQL“The explicit declaration of a contract is not an accident — it's very much the intent of this feature.”
↩︎ Enforcing a contract in YAML, project config or SQL“dbt will include the column names, data types, and constraints in the DDL statements it submits to the data platform”
↩︎ What dbt checks when a contracted model builds“Data Tests are a more flexible mechanism for validating the content of your model after it's built.”
↩︎ Contracts versus data tests, and what counts as a breaking change“When the contract changes in a backwards-incompatible way, it is a breaking change that requires a bump in major version.”
↩︎ Contracts versus data tests, and what counts as a breaking change“It's better to define a set of upfront "guarantees" that define the shape of your model. We call this set of guarantees a "contract."”
↩︎ Key concept“views offer support for column names and data types, but not constraints”
↩︎ Exam trap 1“incremental — with on_schema_change: append_new_columns or on_schema_change: fail”
↩︎ Exam trap 2“and order the columns per the contract instead of your dbt model.”
↩︎ Exam trap 3“This check is agnostic to the order of columns specified in your model (SQL) or YAML spec.”
↩︎ Prediction“versioned models will raise an error. unversioned models will raise a warning.”
↩︎ Checkpoint - 2.
“Introducing governance while models are still changing can complicate future changes.”
↩︎ Why a model needs a contract - 3.
“Even a subtle change in data type, such as from boolean (true/false) to integer (0/1), could cause queries to fail in surprising ways.”
↩︎ Why a model needs a contract“Use a contract enforcement in your dbt_project.yml to enforce contracts consistently across multiple models:”
↩︎ Enforcing a contract in YAML, project config or SQL“If dbt doesn't recognize the data_type name among its known aliases, it will pass it through as-is.”
↩︎ What dbt checks when a contracted model builds“The result is a delta between the YAML-defined contract, and the actual table in the database - which means the contract is now incorrect!”
↩︎ Which models can carry a contract“To avoid this implicit coercion, specify your data_type with a nonzero scale, like numeric(38, 6).”
↩︎ Exam trap 4“When dbt compares data types, it will not compare granular details such as size, precision, or scale.”
↩︎ Checkpoint“Because removing existing columns is a breaking change for contracted models!”
↩︎ Checkpoint - 4.
“Constraints are never applied on ephemeral models or those materialized as view.”
↩︎ Which models can carry a contract