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') }}
  1. dbt run
  • customer_id, country, signup_date
  • cast(customer_id as varchar)
  • plan added to the select
3 versions of the SELECT against 1 contract. The first builds the table, the other 2 fail before a row is written.

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 SELECTBuild resultWhy
Rename `country` to `country_code`fails`country` missing, `country_code` never declared
`cast(customer_id as varchar)`failsvarchar where the contract says integer
Add a `plan` columnfails4 columns where the contract declares 3
Reorder the 3 columnspassescolumns are matched by name, order is free
`upper(country)` in place of `country`passesthe contract checks shape, never values
Drop `signup_date`fails2 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_namedefinition_typecontract_typemismatch_reason
customer_idVARCHARINTEGERdata type mismatch
planVARCHARmissing in contract
signup_dateDATEmissing 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.