Contracts Are How dbt Models Grow Up

Model contracts, constraints, versions, access levels, groups, and the governance features that keep a popular model from becoming a liability.

A model starts as a query. If it becomes useful, other people build on it. Dashboards, notebooks, reverse-ETL jobs, finance exports, and other models quietly assume its columns mean what they meant yesterday.

At that point the model is no longer just an implementation detail. It is an interface. Interfaces need contracts.

dbt’s governance features are not there to make small projects feel enterprise. They are there because a popular model can become dangerous to change. Contracts, versions, access levels, and groups give you a way to evolve the project without breaking everyone at once. This chapter walks through each one on the bookshop marts, and is honest about which parts the database enforces and which parts are documentation with good intentions.

A contract fixes the shape

A model contract says the model must produce the columns and types declared in YAML. Turn it on with a config block and then enumerate every column the model emits:

models:
  - name: orders
    config:
      contract:
        enforced: true
    columns:
      - name: order_id
        data_type: integer
      - name: customer_id
        data_type: integer
      - name: order_date
        data_type: date
      - name: status
        data_type: varchar
      - name: amount
        data_type: double

Two rules kick in the moment enforced: true is set. First, the contract must be complete: every column the model’s SQL returns has to appear in the YAML with a data_type, and every column in the YAML has to be produced by the SQL. If your select returns a discount column that the contract does not mention, the build fails. If the contract declares amount and the SQL forgot to compute it, the build fails. There is no partial contract — you cannot pin three columns and leave the rest floating.

Second, the declared types have to match what the model actually produces. This is the part people underestimate. dbt does not just record your data_type strings as documentation; at build time it compares them against the columns the compiled query yields, and a mismatch is a hard error before the model is even created. If amount comes out of the SQL as a decimal(18,2) because someone rounded a monetary column upstream, and the contract says double, the run stops with a message naming the column, the expected type, and the actual type. This is exactly the place to declare the width you actually emit: the bookshop’s amount is a double, so the contract says double, and any commit that quietly turns it into a decimal gets caught.

That is the whole point. A contract turns “someone changed the grain of stg_orders and now amount comes back as a decimal” from a silent Monday-morning dashboard incident into a red build on the pull request that caused it. The failure lands on the person who broke the shape, not on the finance analyst three hops downstream.

A practical note on types: use the adapter’s real type names, because that is what dbt compares against. On DuckDB integer, bigint, double, varchar, timestamp, and date all behave as you would expect, but be deliberate about width. A count that you sum() can overflow integer on a large warehouse and come back as bigint; declaring it bigint up front avoids a contract failure that has nothing to do with a real bug. Contracts reward you for knowing your own output types, which is a good habit regardless.

Contracts are most valuable on marts and public intermediate models — the fct_ and dim_ tables the rest of the company reads. They are usually overkill on early staging models, where you may still be learning the source shape and re-typing columns every other commit. Enforcing a contract on stg_orders while you are still reverse-engineering the source system just means the contract fails every time you learn something.

One more thing enforcement does not do: it does not validate a single row of data. A contract guarantees order_id is an integer column; it says nothing about whether that column is unique, or non-null, or positive. Shape is not content. That distinction is what the next section is about.

Constraints are not the same as tests

Contracts describe columns. Constraints describe rules the database may enforce on those columns:

columns:
  - name: order_id
    data_type: integer
    constraints:
      - type: not_null
      - type: primary_key
  - name: customer_id
    data_type: integer
    constraints:
      - type: not_null
  - name: status
    data_type: varchar
    constraints:
      - type: check
        expression: "status in ('placed', 'shipped', 'completed', 'returned', 'cancelled')"

Constraints require a contract — you can only declare them on a model where contract.enforced is true, because dbt needs the column list and types to build the constraint into the table’s DDL. When dbt materializes orders as a table, it emits those clauses as part of the create table statement. On DuckDB you can go and look at exactly what it built:

CREATE TABLE orders(
    order_id INTEGER PRIMARY KEY,
    customer_id INTEGER NOT NULL,
    order_date DATE,
    status VARCHAR,
    amount DOUBLE,
    CHECK((status IN ('placed','shipped','completed','returned','cancelled')))
);

The check on status is a genuinely useful one: it pins the column to the five values the bookshop actually uses, so a typo’d shippd never lands. Note the fivecancelled is easy to forget, and forgetting it is instructive: a check that omits a value your data really contains doesn’t quietly warn you, it fails the build with Constraint Error: CHECK constraint failed on table orders__dbt_tmp. Constraints are only as good as your knowledge of your own data, which is rather the point.

The foreign key that can’t be built

You will notice a foreign_key constraint is conspicuously absent above, and the reason is the most useful thing in this chapter. The obvious thing to write is this:

  - name: customer_id
    data_type: integer
    constraints:
      - type: foreign_key
        to: ref('customers')     # ← don't
        to_columns: [customer_id]

Run dbt build on the bookshop with that in place and dbt does not even get as far as the database:

RuntimeError: Found a cycle: model.bookshop.customers --> model.bookshop.orders

That to: ref('customers') is a real ref(), and it adds a real edge to the DAG. But in the bookshop, customers is derived from orders — it aggregates them into lifetime value and order counts. So declaring the FK asks dbt to build orders after customers, which must be built after orders. dbt refuses, and it is right to.

This is not a bookshop quirk; it is a modeling lesson wearing a compiler error. A foreign key points from a fact to a dimension, and in a proper star schema the dimension is genuinely upstream, so the constraint is legal and natural. The bookshop’s customers is not a dimension — it is a customer-level aggregate, sitting downstream of orders. The FK is illegal because the model it wants to point at is on the wrong side of the graph. When a foreign_key constraint produces a cycle, the constraint is usually telling you something true about your model layout.

The obvious next move — point it at the staging model instead, which really is upstream — hits a different wall:

Binder Error: cannot reference a VIEW with a FOREIGN KEY

Staging models are views in this project, and no database will hang a foreign key off a view. And even against a table, DuckDB requires the referenced column to be backed by a primary key or unique constraint:

Binder Error: Failed to create foreign key:
there is no primary key or unique constraint for referenced table

Three walls, one conclusion: a foreign_key constraint is a statement about physical table layout, and it only works when the referenced model is an upstream, materialized table with a declared key. In a star schema you get that for free. Everywhere else, the tool that actually enforces referential integrity is the relationships test — which runs after both models are built, cares nothing for materialization, and works identically on every adapter. That is what the bookshop uses, and it is what you should reach for first.

Here is the honest part, and it is the single most misunderstood thing about dbt constraints: whether a constraint is actually enforced depends entirely on the adapter and the platform underneath it. The YAML is portable; the enforcement is not.

  • not_null is enforced almost everywhere — Postgres, Redshift, Snowflake, BigQuery, Databricks, and DuckDB all reject a null in a not null column. This is the one you can rely on.
  • check is enforced on Postgres, Databricks, and DuckDB, and is not enforced (accepted as metadata) on Snowflake, BigQuery, and Redshift.
  • primary_key, unique, and foreign_key are the treacherous ones. On Snowflake, BigQuery, and Redshift they are informational only — the warehouse records them in the catalog for query-optimizer hints and BI tools to read, and then never checks them. You can happily insert a thousand duplicate order_id values into a Snowflake table with a declared primary key and nothing will complain.

DuckDB is the interesting exception, and worth calling out because it is the engine this series runs on. DuckDB is a real relational engine, so it genuinely enforces every constraint it accepts. Build the bookshop with the constraints above, then reach past dbt and try to break them by hand:

-- duplicate order_id
insert into main.orders select * from main.orders limit 1;
--> Constraint Error: PRIMARY KEY or UNIQUE constraint violated

-- a status outside the allowed set
insert into main.orders values (9999, 1, '2025-01-01', 'shippd', 1.0);
--> Constraint Error: CHECK constraint failed

-- a null customer
insert into main.orders values (8888, null, '2025-01-01', 'placed', 1.0);
--> Constraint Error: NOT NULL constraint failed

All three are rejected by the database, not by dbt. That makes local development a stricter world than a Snowflake production target — and the asymmetry is the trap. A constraint that passes on DuckDB might be silently unchecked in the warehouse. Do not let a green local run convince you the warehouse is enforcing anything.

So why declare primary_key at all if the production warehouse ignores it? Two reasons. It documents intent in the relation itself, where downstream tools — BI catalogs, lineage viewers, some query optimizers — can read it. And it makes the model’s shape self-describing: a person reading the catalog can see that orders is grained on order_id without opening the SQL.

But because enforcement is unreliable, constraints do not replace tests. Tests are the portable way to actually validate the data, and they run the same way on every adapter:

columns:
  - name: order_id
    data_type: integer
    constraints:
      - type: not_null
      - type: primary_key
    data_tests:
      - unique
      - not_null
  - name: customer_id
    data_type: integer
    constraints:
      - type: not_null
    data_tests:
      - not_null
      - relationships:
          arguments:
            to: ref('customers')
            field: customer_id

The constraint is part of the contract; it tells the database and the catalog what the shape is supposed to be. The unique and relationships tests are what actually prove, at build time, on any warehouse, that order_id has no duplicates and that every customer_id in orders really exists in customers. On DuckDB the constraint and the test are belt and suspenders. On Snowflake the test is the only thing standing between you and a duplicate key. Write both, and never let a primary_key constraint lull you into deleting the unique test.

Contracts behave differently by materialization

The mechanism a contract uses depends on how the model is materialized, and it is worth knowing where the enforcement actually happens.

For a table or incremental model, dbt bakes the column list and any constraints into the create table DDL, so the platform is involved: on DuckDB the not_null, primary key, and check clauses are real database constraints from the first build. For a view, there is no persisted column definition to constrain, so dbt enforces the contract at build time by wrapping your query and casting each column to its declared data_type — if a cast is impossible or a column is missing, the create fails. Either way the shape is enforced; only the constraint-checking-on-write behavior is table-specific.

Column order matters more than people expect. dbt compares the contract to the model output positionally as well as by name on some adapters, and constraints like a composite primary key are emitted in the order the columns are listed. So the order of entries under columns: should match the order your final select produces them. If you reorder the select in the SQL, reorder the YAML to match, or a contract that was green yesterday can fail today for a reason that has nothing to do with the data.

Incremental models add one wrinkle. A contract pins the shape of the model, and on_schema_change governs what happens when that shape changes between runs. With a contract in force you generally want on_schema_change: 'append_new_columns' or 'fail' rather than 'ignore', so that a column the contract now requires is actually reflected in the existing table instead of silently absent from historical partitions:

models:
  - name: orders
    config:
      materialized: incremental
      on_schema_change: 'append_new_columns'
      contract:
        enforced: true

The contract catches the shape drift at build time; on_schema_change decides how the already-materialized table is reconciled to the new shape. They are complementary, and on a contracted incremental mart you want both set deliberately rather than left at defaults.

Versions let you change without ambushing people

Suppose customers exposes a column named plan, and half the company uses it — the finance team’s revenue model, three dashboards, and a reverse-ETL sync to the CRM. You realize plan conflates two things that should be separate: the subscription_plan (what tier the customer is on) and the billing_plan (monthly vs. annual). Renaming plan in place is clean for you and rude to everyone else — every consumer breaks the moment you merge.

Model versions give you a migration path. You publish the new shape as a new version while the old shape keeps working:

models:
  - name: customers
    latest_version: 2
    config:
      contract:
        enforced: true
    columns:
      - name: customer_id
        data_type: integer
        constraints:
          - type: not_null
          - type: primary_key
      - name: first_name
        data_type: varchar
      - name: subscription_plan
        data_type: varchar
      - name: billing_plan
        data_type: varchar
    versions:
      - v: 2
      - v: 1
        columns:
          - include: all
            exclude: [subscription_plan, billing_plan]
          - name: plan
            data_type: varchar
        deprecation_date: 2026-09-01

The top-level columns: block describes the latest version. Each entry under versions: describes how that version differs from the top-level definition. Here v2 inherits the full column list unchanged; v1 takes all of those columns except the two new ones and adds back the old plan column. The include: all / exclude: idiom means you only spell out the delta, not the entire schema, for every version.

On disk, dbt looks for the versioned SQL by convention: a file named customers_v1.sql backs v1 and customers_v2.sql backs v2. If your latest version lives in a file with a plainer name, point at it with defined_in:

      - v: 2
        defined_in: customers

latest_version: 2 is what makes an unqualified reference resolve to v2. So this picks up the new two-column shape automatically:

select * from {{ ref('customers') }}

and a consumer that is not ready to migrate pins the old shape explicitly:

select * from {{ ref('customers', version=1) }}

Under the hood, each version is materialized to its own relation — customers_v1 and customers_v2 — so both shapes physically exist while the migration is in flight. That is the cost of versions: you are maintaining and building two models. It is also the point — nobody is forced to move on your schedule.

The deprecation_date on v1 is the social contract made mechanical. Once that date is set, dbt emits a warning whenever a model references the deprecated version, and the date shows up in the docs so consumers can see the clock. The workflow becomes: ship v2, announce it, set a deprecation_date on v1 a quarter out, watch the reference warnings drain to zero as teams migrate, then delete customers_v1.sql and the v1 entry when the date arrives.

Versions are not a license to keep every old shape forever. Two live versions of an important mart is reasonable; five is a museum you now have to keep green in CI. Version the breaking changes people actually depend on, migrate consumers deliberately, and retire old versions on a schedule everyone can see coming.

Access levels define who should depend on what

Not every model deserves to be a dependency. dbt lets you mark a model’s access to say so out loud:

models:
  - name: int_payments_pivoted_to_orders
    access: private
    group: finance
  - name: orders
    access: public
    group: finance
  - name: customers
    access: public
    group: core

The three levels are not just labels — they change what ref() is allowed to resolve:

  • private — only models in the same group may ref() it. int_payments_pivoted_to_orders is finance’s scratch work; a model outside the finance group that tries to reference it fails to compile. This is the level that actually enforces a boundary, and it requires the model to belong to a group.
  • protected — the default. Any model in the same project (or an installed package) may reference it, but another project in a mesh cannot. This is the sensible default for the bulk of your models.
  • public — anyone, including other projects, may depend on it. This is your promise that the model is a stable interface. orders and customers are the bookshop’s public API.

The enforcement is real for private: if dim_marketing_attribution in a marketing group tries to {{ ref('int_payments_pivoted_to_orders') }}, dbt raises a reference error at parse time and refuses to build. That is the mechanism that keeps a “temporary” intermediate model from silently becoming three teams’ shared dependency. If a dashboard — or another team’s model — points at int_tmp_revenue_cleanup, that intermediate model is no longer temporary, and marking it private is how you stop that from happening by accident.

In a small single-team project, access can feel ceremonial, and it partly is — protected is a fine default and you may never mark anything private. In a multi-team project it is how you prevent the graph from becoming a bowl of spaghetti where every team reaches into every other team’s half-finished intermediates. The rule of thumb: marts that other people should build on are public; intermediates that exist only to assemble one mart are private; everything in between is protected and left alone.

Groups put ownership in the project

Access levels lean on groups, and groups do a second useful job: they attach ownership to resources inside the project itself.

groups:
  - name: finance
    owner:
      name: Finance Analytics
      email: [email protected]
      slack: "#finance-analytics"
  - name: core
    owner:
      name: Data Platform
      email: [email protected]

models:
  - name: orders
    group: finance
  - name: customers
    group: core

A group needs a name and an owner; the owner takes a name and any additional keys you like — email, slack, a team handle. Assigning group: finance to a model puts it inside that group, which is what makes access: private meaningful (private is scoped to the group) and what surfaces an owner in the generated docs.

The ownership is partly social and partly mechanical. Mechanically, the owner shows up in dbt docs and in the manifest, so lineage and governance tooling can answer “who owns this?” without a Slack archaeology dig. Socially, it gives a reviewer a name to page when orders needs a breaking change, and it means a model is somebody’s responsibility before it breaks rather than after. A model without an owner is nobody’s problem until it is everybody’s problem.

Ownership does not need to be elaborate. A team name and an email are enough to start; you can grow into richer metadata as the number of groups grows. The value is that the answer to “who owns this interface?” lives in the project, next to the code, and travels with it.

Governance is a gradient

Do not contract every model on day one. That makes experimentation painful and buys you nothing on models no one depends on. Match the governance to the blast radius:

  • staging models — clear naming, basic source and not_null/unique tests, protected access. No contract; the shape is still moving.
  • intermediate models — descriptions where the logic is non-obvious, private access with a group so they cannot leak into other teams’ graphs. Still no contract unless one is shared.
  • marts — grain description, primary-key and relationship tests, protected or public depending on the audience.
  • public martscontract.enforced: true, constraints that document the key and foreign keys, access: public, a group with an owner, and versions the moment you need a breaking change. These are the interfaces, so they get the full treatment.

The more consumers a model has, the stronger its contract should be — and the more the ceremony pays for itself. On orders, which finance, three dashboards, and a CRM sync all read, a contract plus a version plus an owner is cheap insurance. On stg_raw_events, which nobody references yet, it is just friction.

The dbt Mesh idea

Everything so far works inside one project. dbt Mesh is what happens when the same ideas scale across many projects: separate teams own separate dbt projects, and each exposes a small number of public models as the API that other projects consume.

The wiring is a dependencies.yml in the consuming project that names the upstream project:

# in the bookshop_finance project
projects:
  - name: bookshop_core

and a two-argument ref() that reaches across the boundary — first the project, then the model:

select * from {{ ref('bookshop_core', 'customers') }}

The rules you have already met are exactly what make this safe. Only public models are referenceable across projects, so bookshop_core decides deliberately which models are its API and keeps everything else protected or private. Contracts guarantee the shape of those public models, so bookshop_finance can build against them without reading bookshop_core’s SQL. Versions let bookshop_core evolve a public model without breaking bookshop_finance on a Tuesday. The governance features are not separate from Mesh — they are the primitives Mesh is assembled from.

What it is, concretely: a way to split a monolithic project along team lines without giving up a unified lineage graph or a stable interface between the pieces. When you need it: when one project has grown to the point that CI takes an hour, ownership is genuinely divided across teams, and the org chart is starting to show up as merge conflicts. When you do not need it: almost always, at first. Cross-project references via dependencies.yml are a dbt Cloud feature; in dbt Core the equivalent is installing another project as a package, which pulls in its models to build rather than referencing an already-built API. Either way, do not reach for multi-project mesh to solve a problem a few public marts and some private intermediates would solve inside one repo.

In one repo, the principle still holds and is worth internalizing before you ever split: treat public marts as interfaces, treat everything else as implementation, version breaking changes, attach owners, and keep the graph honest about which dependencies are stable. Mesh is that discipline scaled out; the discipline is available to you today without any of the branding.

Final thoughts

The first half of dbt teaches you to build models. Governance teaches you to let other people depend on them without freezing the project forever. Contracts fix shape and fail the build when it drifts. Constraints document intent, and — depending on your warehouse — sometimes enforce it, which is why tests still prove the data. Versions create migration paths so a rename is a deprecation instead of an ambush. Access levels separate public APIs from private scaffolding, and private actually stops the wrong ref() at parse time. Groups make ownership visible where the code lives. Use them where the blast radius justifies them, and your best models can become shared infrastructure instead of accidental traps.

Next: One dbt Project, Many Places to Run It

Comments