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
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.
selectcustomer_id,country,signup_datefrom {{ source('raw', 'customers') }}
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.
- 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
- 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
- ▸Exactly the declared columns, each with its declared data type, checked on every run before the table is written
- ▸A
constraintslist per column, such asnot_null, which the warehouse enforces as rows load where it supports constraints - ▸Never the data in the rows, not even how many there are or how fresh it is
- ▸Tables and views both, with views checked on names and types only
A contract stops country from turning into country_code by accident. Sometimes the change is the plan: the regions team wants country_code and a new region column, while 4 downstream models and a dashboard still read country. A versioned model lets both shapes build on the same run, each under its own contract, so the readers move when their owners choose.
2 files, 1 name
The model is still dim_customers. Its SQL now lives in dim_customers_v1.sql and dim_customers_v2.sql, 1 file per entry under versions. dbt builds 1 relation per file. The default relation names are dim_customers_v1 and dim_customers_v2; the alias under version 1 overrides the first to dim_customers, for a reason the dashboard explains below. The top-level columns list is the shape every version starts from. Version 1 adds nothing, so it inherits all 3 columns. Version 2 says include: all then excludes country, and declares 2 new columns, so its contract has 4 columns: customer_id, signup_date, country_code, region.
What ref() resolves to
| Call | latest_version: 1 | latest_version: 2 |
|---|---|---|
| `ref('dim_customers')` | `dim_customers` (version 1, via its alias) | `dim_customers_v2` |
| `ref('dim_customers', v=1)` | `dim_customers` | `dim_customers` |
| `ref('dim_customers', v=2)` | `dim_customers_v2` | `dim_customers_v2` |
latest_version decides where an unpinned ref('dim_customers') points. Leaving it at 1 while version 2 first appears means nothing downstream changes shape: version 2 builds, sits beside version 1, and a reader opts in with ref('dim_customers', v=2) when its owner is ready. When most readers have moved, flip latest_version to 2 and pin the stragglers with v=1, which compiles to the same relation they always read. A reader's shape changes only when its owner edits it to add a pin or remove one.
Readers outside dbt
A dashboard reads a relation by name and knows nothing about ref() or latest_version. Before versioning, that name was dim_customers. After it, with no alias, dbt builds dim_customers_v1 and dim_customers_v2. The dim_customers relation the dashboard reads stops updating, and neither side reports an error. The alias: dim_customers on version 1 keeps that name attached to the old shape. Moving the alias to version 2 later is the dashboard's cutover, a YAML edit made on the day its owner has changed the panels that read country.
Retiring version 1
deprecation_date on the v: 1 entry puts an end date on the old shape. Every parse then prints a warning for each model that still refs version 1, as an upcoming deprecation before the date and a passed one after it, so every run lists the stragglers. When the list is empty, delete dim_customers_v1.sql along with the v: 1 entry. The same change moves the alias to version 2. The next run builds version 2 into dim_customers, replacing the old shape under the name the dashboard reads, so that relation stays. The one left behind is dim_customers_v2, which version 2 built under its default name until now: drop it after that first run. Retiring version 1 without moving the alias leaves the reverse, a dim_customers frozen in the old shape, to drop once nothing reads it. Versions are for changes of shape. A wrong region mapping gets fixed in version 2 in place, because the shape its readers depend on does not change.
- ▸Dropping a column, or changing its name or type: breaking, so a new version
- ▸Adding a column: additive, so add it to the contract and every reader keeps working
- ▸Changing how a value is computed: a fix in place, since the shape holds
- ▸Changing the grain: a new model, since every reader's joins and sums change meaning
- Keep latest_version on the old shape until most readers have opted into the new one
- Give the version a dashboard reads an alias so its relation name holds through the cutover
- Set a deprecation_date so dbt lists the stragglers on every parse
- Put a contract on every version so each shape is a checked promise
- Rename or retype a column in place when anything outside the model reads it
- Flip latest_version and migrate the readers in the same change
- Retire a version without dropping the relation name that no version builds any more
A snapshot reads a table that overwrites itself and keeps 1 row per version it has seen. On each row, dbt_valid_from and dbt_valid_to bound the time that version held. The intermediate lesson showed that result. Behind it, every run asks 2 questions of every row: has this row changed since the last run, and is this row still there. The strategy answers the first question, the hard_deletes setting answers the second.
timestamp: trust a column
With strategy: timestamp, a source row counts as changed when its updated_at is later than the updated_at the snapshot recorded for that unique_key. The comparison reads 1 column, so it costs little, and dbt_valid_from takes the source's own updated_at, so the history records when the application changed the row rather than when dbt noticed. Both benefits depend on the application keeping updated_at honest. A support tool that edits plan without touching updated_at makes a change the snapshot never records. A nightly job that rewrites updated_at on every row records a new version of every row, each identical to the last apart from its dates.
check: compare the columns
With strategy: check, dbt compares each column in check_cols between the source row and the snapshot's latest row for the same key, counting 2 nulls as equal. A difference in any listed column records a new version. A difference in a column outside the list records nothing. check_cols: all compares every column, at the price of comparing every column on every row. The timeline is dbt's own: dbt_valid_from is the run's timestamp, so a row that changed twice between 2 nightly runs yields 1 version holding the final values, and the history is as fine as the schedule.
snapshots:- name: customers_snapshotrelation: source('raw', 'customers')config:unique_key: customer_idstrategy: checkcheck_cols:- planhard_deletes: new_record
- Changed when 1 column is later than before
- dbt_valid_from is the application's time
- Misses edits that leave updated_at alone
- Records a version for every bump of updated_at
- Changed when any listed column differs
- dbt_valid_from is the snapshot run's time
- Misses edits to columns outside the list
- Records 1 version per run however many edits happened
Choose by what the source gives you. A table with an updated_at the application maintains on every write should use timestamp, which gives the cheaper comparison and the truer dates. A table with no such column, or one where edits arrive from tools that skip it, needs check, with check_cols listing the columns whose history anyone will ask about and nothing more, since every listed column is compared on every row on every run.
When a row disappears
A key present last run and absent this run is a hard delete, and the default, hard_deletes: ignore, does nothing with it: the last version keeps a null dbt_valid_to, and every query for current rows reports a customer who no longer exists. The 2 other settings record the disappearance in different shapes.
| hard_deletes | What the run does to the vanished key | A current-row query sees |
|---|---|---|
| ignore | Nothing | The last version, as if still present |
| invalidate | Sets dbt_valid_to on the last version to the run's time | No row for the key |
| new_record | Closes the last version and inserts a copy of it with dbt_is_deleted set to True | The deletion row, marked as such |
In the animation, run 2 edits 2 rows: customer 1 moves from free to pro, customer 3 moves from US to CA. The snapshot gains 1 row, for customer 1, because country is outside check_cols and the edit to customer 3 is invisible to this snapshot. Run 3 finds customer 2 gone. new_record sets dbt_valid_to on customer 2's open row and inserts a 5th row carrying the same values with dbt_is_deleted set to True, so the table says when customer 2 left and what the row held at that moment.
- ▸
dbt_scd_id: a hash unique to each version of each key - ▸
dbt_updated_at: the source's updated_at under timestamp, the run's time under check - ▸
dbt_valid_fromanddbt_valid_to: the span a version held, null dbt_valid_to meaning still current - ▸
dbt_is_deleted: present only under hard_deletes: new_record, True on the rows that record a deletion
5 edits and what they leave
| Edit | dbt builds on the next run | Left in the warehouse |
|---|---|---|
| Rename `fct_orders.sql` to `fct_orders_daily.sql` | `fct_orders_daily` | `fct_orders`, frozen at its last build |
| Delete `int_order_payments.sql` | Nothing for it | `int_order_payments` |
| Point `fct_revenue` at a different schema | `fct_revenue` in the new schema | `fct_revenue` in the old one |
| Add `versions` to `dim_customers` with no alias | `dim_customers_v1`, `dim_customers_v2` | `dim_customers` |
| Change `materialized` from view to table | `fct_orders` as a table | Nothing: dbt drops the view of the same name first |
The last row marks the boundary. dbt owns the name it is about to build, so a view called fct_orders gives way to a table called fct_orders. The 4 rows above it change the name or the schema, and the old name belongs to nobody. The frozen fct_orders is the dangerous case. The finance dashboard reading it renders every panel. Its monthly total on June 30 equals the total shown on March 3, the morning of the rename, because the table has held the same rows since, and no error appears anywhere.
Why dbt leaves them
Finding them
The warehouse answers with what exists. dbt ls --resource-type model --resource-type seed --resource-type snapshot answers with the nodes the project builds, seeds included because each seed loads a table of its own. dbt ls prints node names, and a relation's name can differ: an alias replaces it, so fct_orders_daily builds fct_orders, and a version without an alias adds _v and its number, so version 2 of dim_customers builds dim_customers_v2. Translate each node into its relation name before comparing. A relation no translated name matches is either one dbt left behind or a table a person created by hand inside dbt's schema. Both deserve a look, and the warehouse's last-modified metadata, where it keeps any, dates the day each orphan stopped changing.
Dropping is a statement run against the warehouse, outside dbt. Run it after the readers have moved, because a dropped table takes the dashboard from stale to broken, and a stale dashboard is at least a dashboard someone can compare against. dbt clean deletes local folders such as target and touches nothing in the warehouse.
Renaming without an orphan
The alias config separates the model's name in the project from the relation's name in the warehouse. fct_orders_daily.sql with alias='fct_orders' builds the relation fct_orders the dashboard already reads, so the rename is visible to every ref() and invisible to the warehouse. 2 models that would build the same relation name in the same schema stop the run at parse time with an error naming both, so an alias can never silently overwrite a neighbour. Without the alias, a rename is 3 changes in a fixed order: rename the file and build, move the dashboard to the new name, drop the old relation. Dropping first leaves the dashboard with nothing, and never dropping leaves it showing frozen numbers.
- Treat every file rename or deletion as a warehouse change with a drop step at the end
- Use alias when the relation name must hold while the model name changes
- List orphans with a catalog query after each round of refactoring
- Drop a relation only after confirming nothing reads it
- Expect dbt run to drop anything whose model is gone
- Trust a dashboard with no errors to be reading fresh data
- Drop the old relation before its readers have moved
Step 1: park the query as a model
Copy the query into models/legacy/legacy_customer_revenue.sql unchanged, apart from swapping each raw table name for a source() call, and materialize it as a table. dbt now builds the result the dashboard trusts on the same schedule as everything else, and that table becomes the reference every later step is measured against. The dashboard can read it at once, which retires the pasted copy on day 1.
Step 2: carve out the layers
Start from the tables the query reads, then follow its joins and its grain. 3 raw tables become 3 staging models, each renaming and casting 1 table, and the test-account filter moves into stg_customers, the only place it belongs. The subquery that sums captured payments per order is 1 grain, 1 row per order, so it becomes int_order_payments, joining orders to their captured total. The outer query sums per customer per month, so it becomes fct_customer_monthly_revenue, 1 row per customer per month, the grain the dashboard reads. Each model does the work the legacy query did at that level and nothing from the levels around it.
Where to cut
A subquery with a GROUP BY is a grain of its own and becomes a model. An expression repeated in 2 places, such as date_trunc('month', o.created) in the select and the group by, becomes 1 column, revenue_month, computed once in int_order_payments. A hard-coded list of ids becomes a seed, so the finance team edits a CSV when a new test account appears, and the staging model joins against it.
The legacy model and the new chain build side by side until a query of audit_revenue returns 0 rows, then the dashboard moves.
Step 3: audit until the difference is 0 rows
The query returns every row in 1 result and missing from the other, tagged by side. 0 rows means the 2 results match for every customer in every month, down to the amount. The first run rarely returns 0. The differences repeat across refactors: a left join written as an inner join drops customers whose orders have no captured payment, a join to payments at the wrong grain doubles an order with 2 payments, round applied per order instead of per month moves cents, and 1 test id in the hard-coded list is missing from the seed. Each difference names a model to fix. After each fix, rebuild the chain and rerun the audit until it is empty. Saving the audit as a view called audit_revenue keeps the query in the project, but building a view only stores the query: nothing runs it, and a non-empty result fails nothing. The proof is a select count(*) from audit_revenue, run after each scheduled build and read by the engineer doing the refactor.
Step 4: swap, then delete
Point the dashboard at fct_customer_monthly_revenue, under a contract that fixes the 3 column names and types the panels read. After the next scheduled run, audit_revenue should still return 0 rows; query it again to confirm. Then delete legacy_customer_revenue.sql and audit_revenue.sql, and drop both relations by hand, as the previous section showed. The 220 lines are now 5 models plus 1 seed. Each model sits at 1 grain and is short enough to read in a minute, and you can build and check any of them on its own.
- ▸Park the legacy query as a model so dbt builds the reference result
- ▸Carve the query into layers, with 1 staging model per source and 1 grain per model above them
- ▸Audit the new mart against the legacy model until 0 rows differ
- ▸Swap the readers, then delete the legacy model and drop its relation
> At Shopify, a merchant-analytics team inherits a 300-line churn query that the growth team's dashboard has run for 2 years. The merchants source table has no reliable last-modified column, merchants who close their stores vanish from it overnight, and the growth team wants the plan column replaced by a tier and a billing-cycle column without their dashboard going dark for a single morning.
dbt enables data analysts and engineers to transform their data using the same practices that software engineers use to build applications.
github.com/dbt-labs/dbt-core
dbt and the dbt logo are trademarks of dbt Labs, LLC.
Promise a model's shape, change it without breaking readers, record vanished rows, and clean up what dbt leaves behind.
- Category
- dbt
- Difficulty
- advanced
- Duration
- 20 minutes
- Challenges
- 6 hands-on challenges
Topics covered: Model Contracts, Model Versions, Snapshot Strategies, Tables dbt Left Behind, Refactoring a Legacy Query
Lesson Sections
- Model Contracts (concepts: Model contracts and versions)
Declaring the shape What dbt does with it Which edits break the promise 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
- Model Versions (concepts: Model contracts and versions)
2 files, 1 name What ref() resolves to Readers outside dbt Retiring version 1
- Snapshot Strategies (concepts: Snapshots)
timestamp: trust a column check: compare the columns When a row disappears
- Tables dbt Left Behind (concepts: Materializations)
dbt's picture of the warehouse is the project. Each run walks the models it parsed and creates or replaces 1 relation per model, named after the model. At the start of a run dbt may list the relations in the schemas it builds into, so it knows whether each name it is about to build already exists, but it acts only on the names its parsed nodes claim. A relation no node claims is never updated and never dropped. So every rename and every deletion in a dbt project leaves a relation behind, built o
- Refactoring a Legacy Query (concepts: Models as queries)
The finance dashboard runs a 220-line query pasted into the dashboard tool 3 years ago, and no one has changed it since. It joins orders to payments through a subquery, filters test accounts with a hard-coded list of ids and a pattern on email, and computes monthly revenue per customer. Refactoring it into models is this section's work, under 1 rule: the new models return the same rows as the old query on every day until the old query is deleted. Step 1: park the query as a model Step 2: carve o