ServicesAboutNotesContact Get in touch →
EN FR
Note

dbt Contract Rollout Strategy

How to adopt dbt model contracts in an existing project — identifying candidates, scaffolding YAML, phased enablement, and CI/CD integration for governance-only checks.

Planted
dbtdata qualitydata modeling

Adopting dbt model contracts in an existing project requires a phased approach to avoid breaking production models all at once. The steps below apply to projects of varying sizes.

Step 1: Identify Which Models Need Contracts

Not every model needs a contract. The best candidates are mart models that serve downstream consumers, especially those marked access: public. Contract enforcement on base or intermediate models adds overhead without meaningful benefit — those layers are internal to your DAG, and their consumers are your own models, not external teams or BI tools.

The dbt-project-evaluator package has a rule called fct_public_models_without_contract that finds public models missing contracts. This gives you a prioritized list of models that other projects or tools depend on but lack schema guarantees.

A practical prioritization:

  1. Public mart models consumed by BI tools or reverse ETL
  2. Models referenced cross-project in a dbt Mesh setup
  3. Models with a history of breaking changes that caused downstream incidents
  4. High-visibility models feeding executive dashboards or external partners

Step 2: Scaffold the Initial YAML

For existing models, dbt-codegen generates contract-ready definitions:

Terminal window
dbt run-operation generate_model_yaml \
--args '{"model_names": ["mrt__analytics__customers"], "include_data_types": true}'

Review the output carefully. Three common issues in generated YAML:

  1. Numeric types lacking precision. dbt-codegen may output numeric when the actual column uses numeric(38,2). Bare numeric defaults to scale=0 on some platforms, which stores only whole numbers. Always specify precision and scale explicitly for decimal columns.

  2. Type aliases that don’t match. The generated type might be STRING but your SQL produces VARCHAR. dbt handles aliasing, but verify that the generated type aligns with what your SQL actually returns.

  3. Missing columns. If your model uses SELECT *, dbt-codegen captures the current columns, but a source schema change could add columns that aren’t in your contract. Consider replacing SELECT * with explicit column lists before enabling contracts.

Before enabling enforcement, run dbt compile to surface type mismatches without affecting any built tables. The compile step performs the preflight check without executing any DDL.

Step 3: Enable Contracts in Phases

Phase 1: Start Small

Enable contracts on a handful of mart models first. Start with not_null constraints (the only constraint enforced on every major platform) and add informational constraints like primary_key with warn_unenforced: false. Pair unenforced constraints with dbt tests.

models:
- name: mrt__analytics__customers
config:
contract:
enforced: true
columns:
- name: customer__id
data_type: integer
constraints:
- type: not_null
- type: primary_key
warn_unenforced: false
data_tests:
- unique
- not_null
- name: customer__name
data_type: text
- name: customer__lifetime_value
data_type: numeric(38,2)

Run the contracted models in your development environment. Fix any type mismatches the preflight check catches. Deploy to production only when the builds are clean.

Phase 2: Extend to More Marts

Once the first batch is stable, extend to more mart models. Use directory-level configuration to enable contracts across entire mart folders:

dbt_project.yml
models:
my_project:
marts:
+contract:
enforced: true

This approach forces you to add contract YAML for every mart model, which surfaces any models that are missing column definitions.

Phase 3: Cross-Team Boundaries

Extend contracts to intermediate models that act as cross-team API boundaries. These are models where one team produces data that another team consumes. Even within the same dbt project, contract enforcement on these boundaries prevents one team’s refactoring from silently breaking another team’s downstream models.

Step 4: Integrate with CI/CD

CI/CD is where contracts deliver the most value. Breaking changes surface in pull requests before anyone merges anything.

In dbt Cloud, CI jobs use state:modified+ to compare the current manifest against the last successful production run. Breaking changes appear directly in pull request comments. The versioning system handles legitimate schema evolution, while contracts catch unintentional changes.

For dbt Core users, the --empty flag (available since 1.8) enables governance-only CI checks:

Terminal window
dbt build --select state:modified+ --empty --defer --state ./prod-manifest

This builds table structures and validates contracts without processing any data. Schema validation runs at near-zero compute cost, which makes it practical to check every pull request.

The --empty flag is significant because it decouples schema validation from data processing. Without it, CI jobs had to either run against real data (expensive, slow) or skip contract validation entirely. With --empty, you get full contract and constraint validation with minimal warehouse cost. A CI job that would take 20 minutes and scan terabytes completes in seconds.

Common Pitfalls

Enabling contracts on too many models at once. The initial YAML scaffolding almost always has type mismatches. Enable contracts model by model so you can fix issues incrementally rather than drowning in a wall of compilation errors.

Forgetting to update contracts when changing SQL. A contract is a two-file change: the SQL and the YAML. If you add a column to your SQL but forget to add it to the YAML, the build fails. Some teams add a CI check that warns when SQL files are modified without corresponding YAML changes.

Using SELECT * in contracted models. Contracts require every column to be declared. SELECT * means any upstream schema change adds a column to your model that isn’t in the contract, breaking the build. Replace SELECT * with explicit column lists in any contracted model.

Ignoring the warn_unenforced flag. Without warn_unenforced: false on informational constraints, dbt generates warnings on every build. Over time, teams stop reading warnings entirely, and real issues get buried. Suppress warnings for constraints you’ve deliberately chosen to handle with tests.

Adoption trajectory

The first few contracted models require the most effort — learning the type system, fixing YAML, and establishing patterns. By the twentieth model, scaffolding and enabling a contract takes minutes.

The primary value of CI/CD integration is that every schema change becomes reviewed and intentional. Column type changes that previously silently broke dashboards surface in pull requests before merging.