cswork / libraryOpen practice workspace

Articles

dbt Model Contract Interview Questions: Types, Breaking Changes, and Versioned Models

Practice dbt model contracts with type-check failures, adapter-specific constraints, versioned refs, and a staged consumer migration.

A dashboard expects dim_customers.country_name as text. An engineer replaces it with country_code, updates the SQL, and every downstream ref('dim_customers') begins to build against the new shape. A dbt contract can catch an unintended mismatch while building the model, but it cannot decide whether the migration is acceptable to every consumer. What would you do before changing the shared model?

The dbt model-contract documentation defines contracts as guarantees about column names, types, and supported constraints. The v1-to-v2 customer model below is an original interview exercise, not a candidate report or a claim about a named employer. No verified same-cycle candidate report establishes this exact prompt. The preparation target is a defensible migration, not memorized YAML. For a broader source-to-consumer integrity question, see Explain ETL schema changes and ensure integrity.

Question 1: What does an enforced model contract guarantee?

Start with a small model. dim_customers v1 exposes customer_id as an integer and country_name as text. Its SQL selects those two columns. In its properties YAML, config.contract.enforced is true, and every output column has a declared name and data_type. dbt's official contract guide says the build performs a preflight check that the query's result shape matches the declared names and types. A mismatch causes the model build to fail rather than quietly replacing the promised interface.

models:
  - name: dim_customers
    config:
      materialized: table
      contract: {enforced: true}
    columns:
      - name: customer_id
        data_type: int
      - name: country_name
        data_type: varchar

The types in a real project must be valid for its adapter and database; do not assume that every warehouse treats int, integer, number, and varchar identically. The contract checks the declared output, not all semantics of the SQL. If country_name still has type varchar but now contains ISO codes, preflight can pass while a dashboard label breaks. If customer_id is declared as an integer but duplicates exist, a shape check alone does not establish uniqueness.

Name the contract's boundary precisely. It protects consumers from accidental changes in columns and types; it is not a complete data-quality program, deployment guarantee, or historical-meaning certificate. dbt documents support for SQL models materialized as tables, views, and certain incremental configurations, with important limits. Views can enforce shape but not database constraints; ephemeral and materialized-view models are not supported in the same contract path. Check the current prerequisites for the dbt track and adapter in the actual interview environment rather than reciting a blanket rule.

Question 2: How are contracts, constraints, and data tests different?

Use a three-column explanation. A contract checks the model output shape before the build completes. A database constraint, where supported and enforced, can reject invalid rows during materialization, such as a non-null violation. A dbt data test queries the built data for a rule such as uniqueness or accepted values; it can produce failing rows or configurable severity. The dbt guide explicitly separates shape guarantees from flexible post-build tests.

For customer_id, suppose v1 must have no nulls and no duplicates. A not_null constraint could be appropriate if your platform and materialization enforce it at build time. A unique or primary-key declaration is not automatically an enforced database guarantee across warehouses. dbt's constraint reference groups support as definable-and-enforced, definable-but-not-enforced, or unsupported. It warns that some warehouses accept primary-key metadata without rejecting duplicate rows. A unique data test, run as part of CI or deployment, may still be needed. State the adapter and the failure behavior you have actually verified.

Consider a two-row bad fixture: (customer_id=7, country_name='US') and (customer_id=7, country_name='CA'). Both rows satisfy the declared column types. The contract may pass. If uniqueness is not enforced by the warehouse, the model may build. The data test should then fail. The practical lesson is to list each business invariant and its enforcement layer, rather than saying “we have a contract, so the table is safe.” For a high-impact public model, record the data-test command and severity alongside the contract, and verify the production job actually runs them.

The distinction also matters for view models. The view's declared shape can be checked, but row-level constraints are not applied like they are on supported table or incremental materializations. Even on tables, constraint enforcement varies by adapter and can change with a platform upgrade. A careful interview answer says what dbt checks in project code and what the underlying warehouse enforces, then describes how to test the gap.

Question 3: Which changes are breaking?

In the exercise, replacing country_name with country_code removes a column and adds another. The removal is breaking for consumers that select country_name, even though country_code is also text. Changing customer_id from integer to string is a type change and can break joins and interfaces. Removing or altering a constraint can weaken a guarantee. dbt's version-property documentation lists removed columns, type changes, and changed constraints among contract-breaking changes detected against prior project state in the relevant CI workflow.

Adding a nullable country_code while retaining country_name is usually an additive schema change. It may still create semantic or operational work: backfilling codes, validating mapping, preventing mixed meanings, and updating dashboards. A changed calculation with the same name and type can be a major business break even if a structural contract diff sees no type change. The model-version guidance treats such semantic cases as a judgment call for model owners. Distinguish a mechanical contract verdict from a consumer-impact decision.

Proposed edit to v1Contract/consumer issueSafer response
Add country_code and keep country_nameGenerally additive shape; mapping still needs testingIntroduce, backfill, document, then migrate
Remove country_nameExisting select statements failProvide v2 and a deprecation window
Recast customer_id from integer to textJoin and type expectations changeCreate a versioned interface or coordinate consumers
Change country value from name to code under same columnShape may still pass but meaning changesTreat as semantic break; do not rely on contract alone
Add a uniqueness testValidation changes, not necessarily shapeDecide blocking severity and repair duplicates

This decision table is specific to the customer model. It prevents the common mistake of treating “YAML compiles” as proof that a change is safe. A consumer may be an external dashboard with no dbt ref in your repository; impact analysis must include it.

Question 4: How do versioned models protect existing consumers?

Keep v1 available while introducing v2 with country_code. dbt's model-versions guide describes versioning as a way to offer a migration window for breaking changes. Declare versions in model properties, give each version an implementation, and initially leave latest_version: 1. A consumer pinned to {{ ref('dim_customers', version=1) }} stays on v1. A consumer using unpinned {{ ref('dim_customers') }} resolves to whatever version is marked latest, as explained in the ref documentation and latest_version reference.

That last behavior makes cutover an explicit event. If you change latest_version from 1 to 2 while unpinned consumers still expect country_name, their next build can break. The safe migration is to inventory consumers; pin ones that need v1; build and validate v2; move consumers with owner sign-off; only then move the latest pointer. Keep v1 through a published deprecation window and remove it after confirming no remaining dependencies. A dashboard outside dbt may reference a physical relation name directly, so a dbt ref graph alone is not a complete inventory.

There is a second trap: “versioning” does not mean the warehouse automatically rewrites v1 data into v2 or migrates a dashboard. You need two working models and an operational plan for freshness, access, backfills, and ownership. The v2 model can compute country_code from a mapping, but the mapping's coverage and effective-date policy are separate correctness questions. If a country name maps ambiguously, treat that as a data issue and do not quietly emit a code that happens to pass the declared type.

Question 5: What should CI and deployment prove?

Compare project state so dbt can identify an unintended breaking contract edit. The versions reference describes state:modified checks that surface breaking changes for versioned contracts in Slim CI. However, a CI error is only one signal. Run the appropriate model build, data tests, and a small consumer query for v1 and v2. Confirm customer_id uniqueness, mapping coverage, and expected row count by segment. Record the dbt and adapter versions, since supported contracts and constraints vary.

A compact acceptance plan for this exercise is: first, v1 still returns customer_id and country_name; second, v2 returns customer_id and country_code; third, both have the same intended customer population, except for explicitly documented exceptions; fourth, the v2 country mapping passes a reviewable sample and unmapped-code check; fifth, pinned consumers build against their chosen version; sixth, the latest pointer is switched only after unpinned consumers are ready. Monitor the next scheduled production run and dashboard refresh after cutover. A successful dbt build in an isolated development schema does not prove that every external query has migrated.

If the interviewer asks whether a full refresh is necessary, separate schema cutover from incremental state. A versioned model might be a view, table, or incremental table, and rebuilding/backfilling depends on that materialization and the mapping change. Do not give an unconditional “yes” or “no.” Explain what data must be recomputed, which relation readers see during the change, and what a rollback to v1 would restore. If v2 is wrong after launch, keeping v1 live gives a real rollback target; merely changing YAML without preserved data does not.

A concise answer you can defend

“I would enforce a contract on the public dim_customers output so a query that accidentally changes column names or types fails its preflight. I would still test data invariants, because a contract does not guarantee unique IDs or semantic correctness, and warehouse constraints are adapter-dependent. Removing country_name is breaking, so I would build v2 with country_code alongside v1, pin consumers where necessary, validate the mapping and row population, then deliberately move latest_version once unpinned consumers are ready. I would retain v1 for a defined deprecation and rollback period.”

If pressed about an additive column, say it often does not require a version bump mechanically, but it still needs a mapping and rollout decision. If pressed about a same-type semantic change, say the contract may pass and a consumer could still be harmed. Those follow-ups distinguish an engineer who understands the interface from one who only knows enforced: true.

Sources and further reading