Model Contracts
Concepts covered: Model contracts and versions
A model's table has whatever columns its SELECT returns. If you rename country to country_code in dim_customers.sql and run the model, the table changes shape and dbt raises no complaint. The complaints arrive later, from the 6 models and 2 dashboards that read country, each failing at its own build time or rendering a blank panel. A model contract moves that failure to dim_customers itself, before it writes a single row.
Declaring the shape
models:
- name: dim_customers
config:
materialized: table
contract:
enforced: true
columns:
- name: customer_id
data_type: integer
- name: country
data_type: varchar
- name: signup_date
data_type: date
This is a properties file, the same kind of YAML that declares sources, with a models: list in place of sources:. On its own, the columns list documents the model. contract: enforced: true under config turns it into a promise dbt checks on every run. Each data_type is written in the warehouse's own type names, the words it prints when asked to describe a column. A properties file can hold several models; 1 file beside each model, named like it, keeps the promise next to the SQL it governs.
What dbt does with it
Before the model runs, dbt compiles the SELECT and asks the warehouse which columns it would return, by running the query inside a wrapper that matches no rows. The answer is a column list with a type for each. dbt checks that list against the YAML: the SELECT must return exactly the declared columns, each with its declared type. A mismatch fails the model with a table of the differences, and nothing is written. A match lets dbt create the table with the declared types first and load the SELECT's rows into it afterwards, so the warehouse holds integer for customer_id even on a day the query would have inferred something wider.
select
customer_id,
country,
signup_date
from {{ source('raw', 'customers') }}
- dbt run
- customer_id, country, signup_date
- cast(customer_id as varchar)
- plan added to the select
That early failure is the reason to add a contract. Suppose the application team migrates customer ids to strings on a Tuesday night. Without a contract, dim_customers rebuilds with a varchar key, the 6 models joining it on customer_id fail 1 by 1 over the next hour, and the dashboard that casts nothing shows 0 customers. With the contract, dim_customers fails first with 1 line naming the column and the 2 types, the 6 models never run, and yesterday's table stays in place for every reader until the fix lands.
Which edits break the promise
| Edit to the SELECT | Build result | Why |
|---|---|---|
| Rename `country` to `country_code` | fails | `country` missing, `country_code` never declared |
| `cast(customer_id as varchar)` | fails | varchar where the contract says integer |
| Add a `plan` column | fails | 4 columns where the contract declares 3 |
| Reorder the 3 columns | passes | columns are matched by name, order is free |
| `upper(country)` in place of `country` | passes | the contract checks shape, never values |
| Drop `signup_date` | fails | 2 columns where the contract declares 3 |
Type names are compared as the warehouse prints them, so a contract that says varchar where the warehouse reports text fails on a system that treats the 2 as different words, and passes on one that treats them as aliases.
Reading the failure
| column_name | definition_type | contract_type | mismatch_reason |
|---|---|---|---|
| customer_id | VARCHAR | INTEGER | data type mismatch |
| plan | VARCHAR | missing in contract | |
| signup_date | DATE | missing in definition |
definition_type is what the SELECT returns, contract_type is what the YAML promised. A row with an empty contract_type is a column the SELECT produces that the YAML never mentioned, and a row with an empty definition_type is a promised column the SELECT lost. The fix goes either into the SQL or into the YAML, and the choice has a consequence: an edit to the YAML changes the promise, so every reader of the model is told before it lands.
- Without a contract
- The table takes the SELECT's shape on every run
- A changed column is found by whichever reader hits it first
- Readers fail or show wrong panels, 1 at a time, after the write
- With a contract
- The YAML's shape is checked before the write
- A changed column is found in the model that changed
- Readers keep yesterday's table until the model builds again
About this lesson
This section is part of the Models: Advanced lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.