What the Fusion engine changes, how a migration actually goes, the anti-pattern catalogue with fixes, and a review checklist you can hand to a team.
⏱ 78 min readTopics chapter readerLevel · Hard
01 · Four names, one alpha, one proprietary binary
Two Engines, One Project
Four things, not two. dbt Core v1.x is the Python implementation — Apache 2.0, stable, what you run today. dbt Core v2.0 is the Rust RUNTIME, relicensed out of Fusion under Apache 2.0, and it is an alpha. The dbt Fusion engine is a free binary containing that runtime plus, in dbt Labs' words, "some proprietary code" — free to use, still proprietary, licensed under its own agreement. dbt platform is the commercial SaaS, formerly dbt Cloud. Licence, implementation, distribution and hosting are four separate axes, and every confusing sentence about Fusion is two of them collapsed into one.
⏱ 4 min · Topic 1 of 9
Twenty-one chapters have targeted one thing: dbt Core 1.12.0, the Python implementation, current stable since 16 July 2026. That is still the right target. But the names around it moved twice in fourteen months, and the version of this story most engineers are carrying — "Core is the Python one, Fusion is the proprietary Rust one" — stopped being true on 1 June 2026.
The widget below runs the same faults through three engines. Ignore speed. The question that decides a migration is how much of your project each engine understands before it sends anything to a warehouse.
Core mental model
Four things, not two. dbt Core v1.x is the Python implementation — Apache 2.0, stable, what you run today. dbt Core v2.0 is the Rust RUNTIME, relicensed out of Fusion under Apache 2.0, and it is an alpha. The dbt Fusion engine is a free binary containing that runtime plus, in dbt Labs' words, "some proprietary code" — free to use, still proprietary, licensed under its own agreement. dbt platform is the commercial SaaS, formerly dbt Cloud. Licence, implementation, distribution and hosting are four separate axes, and every confusing sentence about Fusion is two of them collapsed into one.
Why it matters
Getting these four names wrong costs real money in two directions. Teams migrate to "the open-source Rust engine" and discover the binary they installed is proprietary and the Apache-licensed runtime is an alpha with no beta and no RC behind it. Other teams refuse to look at Fusion because "it is closed", and give up a static-analysis capability that catches a class of defect — an unresolvable column — that dbt Core 1.x structurally cannot see, since it templates SQL rather than comprehending it. The second cost is quieter: on dbt Core 1.x there is no column-level lineage at all, which Chapter 11 established, so every impact question is answered by reading models rather than by asking the tool.
dbt Core 2.0, precisely
The Rust runtime, open-sourced out of Fusion and released as dbt Core under Apache 2.0 on 2026-06-01. Current build 2.0.0a5 (git tag v2.0.0-alpha.5), 20 July 2026. There is no beta, no release candidate and no GA — anyone who tells you dbt Core 2.0 is stable has not read the tag.
The dbt Fusion engine
A free binary distribution built on the v2 runtime plus proprietary code, licensed under the agreement published at getdbt.com/dbt-fusion-engine-license-agreement — that is the page title; the document itself opens by naming itself the "dbt Product Licensing Agreement." Not deprecated: the docs (updated 2026-07-31) describe it as the default experience when you install dbt. Free to use is not the same as open source, and both halves of that sentence are load-bearing.
static_analysis
A Fusion-only config, ignored by dbt Core, taking strict | baseline (default) | off. It is settable in dbt_project.yml (+static_analysis:), in a model's YAML config:, or via {{ config(static_analysis='off') }}, with a --static-analysis CLI flag. Setting it off costs syntax and type checking, CTE previews, and column-level lineage — and it PROPAGATES: a child can never be stricter than its parent.
v2 adapters are re-implemented, not recompiled
The v2 adapters are written in Rust inside the dbt-core monorepo and connect through a pre-existing ADBC driver — dbt Labs do not write drivers. The extension point is an AdapterType enum plus match adapter_type() arms across roughly thirteen files. Your existing Python dbt-<adapter> package does not work on v2; only the Jinja macro layer ports over. That is why the adapter matrix is short and why it grows slowly.
Support windows, which are dated — unlike the sunset, which is not
One year of support per minor: 1.12 is in active support until 2027-07-15, 1.11 is critical-only until 2026-12-18, 1.10 is deprecated. There is no announced end for the 1.x line at all. dbt Labs, verbatim: "You don’t have to move to v2.x today, tomorrow, or ever."
One project, four faults and two non-faults — where does each engine tell you, and what does it know?Same repo, same Snowflake account, same models. The interesting question is not which engine is faster; it is how much of your project each engine understands before it sends anything to a warehouse. Pick a fault.
the fault
-- models/marts/mart_episode_catalogue.sql
select
episode_id,
episode_titel -- transposed, and upstream has no such column
from {{ ref('int_episode_catalogue') }}
dbt Core 1.12.0Python · Apache 2.0 · stable, 16 Jul 2026 · what this KB targetsWAREHOUSE — at run time, from the databaseNot caught. dbt Core 1.12 renders Jinja into a string and sends it; the string is only SQL to the database. You find out from Snowflake, at run time, in whatever environment ran it first.
dbt Core 2.0.0a5the Rust runtime · Apache 2.0 · ALPHA, 20 Jul 2026 · no beta, no RC, no GANOT KNOWN — the engine has no answer to giveNot a documented capability of the open runtime. dbt Labs document static analysis as a Fusion feature and document its config as ignored by dbt Core — so this chapter does not claim the Apache-licensed runtime catches it. Where a source is silent, the honest answer is "unknown", not "probably".
dbt Fusion enginethe v2 runtime plus some proprietary code · a free binary under a proprietary licenceKNOWN — the engine can answer this without runningThis is the difference worth caring about. Fusion comprehends the SQL rather than templating it: dbt Labs describe static analysis as providing syntax and type checking, CTE previews and column-level lineage, and describe turning it off as costing exactly those. The engine can tell you the column does not exist without asking the warehouse.
1.12 · caught before the warehouse3 of 4the unresolvable column is the one that gets away
2.0 alpha · caught before the warehouse3 of 4parse-time strictness, from the runtime
Fusion · caught before the warehouse4 of 4plus the column question, which the other two cannot answer at all
Four names, and only one of them is a licence you already understand
Name
What it is
Licence
Status on 2026-08-04
dbt Core v1.x
The original Python implementation
Apache 2.0
Stable — 1.12.0, 16 Jul 2026. What this KB targets.
dbt Core v2.0
The Rust runtime, open-sourced out of Fusion
Apache 2.0
ALPHA — 2.0.0a5, 20 Jul 2026. No beta, no RC, no GA.
dbt Fusion engine
A free binary: the v2 runtime plus some proprietary code
Proprietary — "dbt Fusion Engine License Agreement" (page title); the document itself is the dbt Product Licensing Agreement
Not deprecated. The docs (upd. 2026-07-31) call it the default experience when you install dbt.
dbt platform
The commercial SaaS, formerly dbt Cloud (renamed May 2025)
dbt Labs, verbatim on 2026-06-01: "The subset of code (we’re calling it the runtime) which we previously committed to releasing publicly under the ELv2 license is now under the Apache 2.0 license as dbt Core." And, in the same post, "…one called Fusion providing the best experience in a binary containing some proprietary code." Both halves matter: the runtime is Apache-2.0 and alpha; the binary is free to use and still proprietary.
The refusal most learners will meet firstworked example
Config
The 'postgres' adapter is not yet supported by dbt Fusion.
Supported adapters: snowflake, bigquery, databricks, redshift
Quoted from dbt-labs/dbt-adapters#1992 (opened 2026-06-01, closed 2026-06-09) — not from a run of this project, which is on Snowflake. Postgres is the adapter most people learn dbt on and run locally, and it is the one Fusion does not support. dbt Labs have said Postgres support is planned; it is not supported today, and this chapter will not put a date on a plan.
Fusion support is per adapter — never say "Fusion is GA" unqualified
Adapter
Fusion status (docs updated 2026-07-31)
What that means for a team
Snowflake
GA
The one adapter where "should we adopt it in production" is a real question rather than a bet
BigQuery
Preview
Fine in development; a production switch is a decision about your appetite for preview software
Databricks
Preview
Same shape as BigQuery
Redshift
Preview
Same shape again — and note Cost Insights is only Preview here too
Spark
Beta — CLI only
Per-surface, not just per-adapter: the CLI qualifier is the part people drop when they repeat this
DuckDB
Beta — CLI only
Same qualifier
Postgres
NOT SUPPORTED
The single most common local development setup in the dbt world. This is the row to check first, not last.
Status is per adapter AND per surface. "Fusion is GA" and "Fusion is preview" are both wrong as unqualified sentences, and one of them is wrong in the expensive direction.
The surfaces around the engine, each with its own status
Surface
Status on 2026-08-04
The detail that changes what you do
dbt lint / dbt format
Beta — v2 and Fusion only, built into the dbt platform
SQLFluff-compatible: it reads .sqlfluff, uses the same rule codes (CP01, RF03) and honours -- noqa. On dbt Core 1.12 you are running SQLFluff, not dbt lint — do not send a 1.12 reader to it. No speed figure is printed here: two conflicting vendor numbers exist and no independent one does.
dbt Docs v2
Alpha
Needs Fusion or dbt Core 2.0. dbt compile --write-index, then dbt docs serve. Column-level lineage only on Fusion with --static-analysis strict.
dbt State
Preview
Works with dbt Core, Fusion, dbt platform and external orchestrators — Chapter 15 handed it forward with exactly that framing. Config lag_tolerance. Usage-billed, with billing beginning 2026-09-01.
Cost Insights
GA on Snowflake, BigQuery and Databricks (Jul 2026); Redshift Preview
The cost attribution Chapter 16 built by hand, as a product — on three adapters.
Column-level lineage
Two paths, neither of them free and open
dbt Catalog on dbt platform is GA but Enterprise / Enterprise+ only. dbt Docs v2 on Fusion or Core 2.0 is alpha and needs --static-analysis strict. It is not in dbt Core 1.x at all, which Chapter 11 established and this confirms.
Common mistake
Reading "the runtime is now Apache 2.0" as "Fusion is open source now". You tell a procurement or legal reviewer something false, and they find out from the licence file. Only a subset — the runtime — was relicensed, and it ships as dbt Core 2.0 in alpha. The Fusion binary you actually install is governed by the dbt Fusion Engine License Agreement and contains proprietary code by dbt Labs’ own description. The correct sentence has both halves in it.
Reaching for static_analysis: off to make a stubborn model migrate. You pay the entire migration and keep none of the benefit. off costs syntax and type checking, CTE previews and column-level lineage — and because a child can never be stricter than its parent, everything downstream of that model loses them too. The worst part is that it looks like success: the build is green, and you believe you have migrated.
Better habit
Say "dbt platform (formerly dbt Cloud)" the first time in any document that will outlive the week. Half your readers learned the old name; the other half have never seen it.
Before quoting a Fusion status, name the adapter and the surface. "GA on Snowflake; Postgres not supported" is a sentence you can defend. "Fusion is GA" is not.
Put two dates in a calendar rather than a migration in a roadmap: your adapter’s Fusion status, and your minor version’s support window. Those are the only two things that will actually force your hand.
Custom materializations work on v2 — and that is not the same as being fine
The engine renders your Jinja to DDL exactly as before, so your custom materialization keeps working. What you lose is static analysis on that node: the engine cannot know whether your materialization changes columns, so it silently skips analysis for it. No error, no warning. You migrate, you believe you have column-level lineage, and on that subtree you quietly do not. Pre-flight every custom materialization before the migration, because nothing in the migration will mention them.
Python models do run on the v2 engine
On Snowflake, BigQuery and Databricks, Python models are functional on the v2 engine. They sit outside the static-analysis story for the reason Chapter 21 spent a chapter on: dbt never reads your Python, so a Python model cannot be analysed as SQL. dbt Labs describe that as the last open question before GA. No source says Python models need static_analysis turned off, so this chapter does not say it either.
The dbt-fusion repository was retired in favour of dbt-core and its issues moved. It is not archived — GitHub reports archived: false — and its status is not evidence about the engine’s future in either direction. Chapter 21 made the same correction about dbt-redshift’s move into the adapters monorepo. A repository’s housekeeping is not a product decision.
"What is the difference between dbt Core, dbt Cloud and Fusion?"
The weak answer names two things and gets the licence wrong. The strong answer names four and separates the axes: dbt Core v1.x is the Python implementation under Apache 2.0; dbt Core v2.0 is the Rust runtime, also Apache 2.0, currently an alpha; the Fusion engine is a free binary built on that runtime plus proprietary code, under its own licence; and dbt platform is the commercial SaaS, renamed from dbt Cloud in May 2025. Then land the part that shows you have actually used it: status is per adapter and per surface, Snowflake is GA and Postgres is not supported at all.
Remember this
Four names, four axes: implementation, licence, distribution, hosting. The runtime is Apache-2.0 and alpha; the Fusion binary is free and proprietary; support status is per adapter and per surface; and there is no announced end for the 1.x line you are running today.
02 · True, false, and the bucket nobody has
Claims With Expiry Dates
the date you verified it, and where you verified it. "Fusion supports Snowflake — GA, supported-features page, read 2026-08-04" survives a challenge. "Fusion is GA" does not survive contact with a Postgres shop.
⏱ 4 min · Topic 2 of 9
Most wrong things a senior engineer says about this ecosystem are not wrong. They are expired: true when they were learned, and quietly overtaken since. Nothing about the sentence changed, so nothing about it feels wrong.
Eight claims below, each marked TRUE, TRUE-BUT-EXPIRED or FALSE, each revealing with a date and a source. Four of the eight were true when somebody first wrote them down.
Core mental model
Every claim you repeat about tooling carries an invisible expiry date, and the half-life in this specific space is roughly a year. So attach two things to any claim before you say it out loud: the date you verified it, and where you verified it. "Fusion supports Snowflake — GA, supported-features page, read 2026-08-04" survives a challenge. "Fusion is GA" does not survive contact with a Postgres shop.
Why it matters
The single fact that most changes how this comparison should be argued is an org fact, not a feature fact. Fivetran acquired Tobiko Data — SQLMesh and SQLGlot — on 2025-09-03, contributed SQLMesh (the open-source framework, not Tobiko Cloud) to the Linux Foundation on 2026-03-25, and completed its merger with dbt Labs on 2026-06-01. So since June 2026 the two tools most often framed as rivals share a corporate parent, and one of them sits under neutral foundation governance. Walking into an architecture review with "which one wins" is now a question about a company that owns both.
Expired versus wrong
An expired claim was true and has been overtaken; a wrong claim never was. They need different responses. Expired claims are fixed by a date stamp and a re-read; wrong claims are fixed by finding out where you got them, because that source is probably still feeding you.
Fivetran + dbt Labs
Announced 2025-10-13, completed 2026-06-01. The combined company operates as "Fivetran + dbt Labs", with George Fraser as CEO and Tristan Handy as co-founder and President. The dbt brand and product line continue. The tense is the finding: a future-tense claim that has already happened is the least likely kind to be challenged in a meeting.
SQLMesh today
v0.236.1, released 2026-07-24, Apache-2.0, repository active, under Linux Foundation governance since 2026-03-25. Still 0.x after roughly four years — state that neutrally as a maturity signal rather than as an insult, because plenty of 0.x software is production software.
Virtual data environments
SQLMesh's documentation describes promotion between environments as a pointer swap rather than a rebuild, which makes development environments effectively free. dbt has no equivalent primitive; the nearest thing in this knowledge base is Chapter 14's deferral, which avoids rebuilding but does not make promotion atomic.
Where SQLMesh is genuinely better — every row attributed, none benchmarked
Capability
What SQLMesh’s documentation describes
The honest dbt comparison
Virtual data environments
Blue-green promotion as a pointer swap; development environments are free
dbt has no equivalent primitive. Chapter 14’s --defer avoids rebuilding upstream models; it does not make a promotion atomic.
Change categorisation
Automatic breaking / non-breaking classification of a change, with the backfill that implies
dbt has no equivalent. Deciding whether a change needs a backfill is a human judgement in every dbt project, which is why it is item B6 on this chapter’s checklist.
SQL comprehension
Parsing and transpilation via SQLGlot
This is the row that decayed. The v2 runtime and Fusion comprehend SQL natively rather than templating it, so the moat narrowed considerably — see section 1’s unresolvable-column fault.
Column-level lineage
Available in the open-source tool
The sharpest live contrast. dbt’s CLL is dbt Catalog on dbt platform (GA, Enterprise / Enterprise+ only) or dbt Docs v2 on Fusion / Core 2.0 (alpha, needs --static-analysis strict). Not in dbt Core 1.x at all.
Incremental by time range
Handled by the framework from a declared time column
dbt hands you is_incremental() and a lookback you size yourself — the whole of Chapter 9, and the source of two of that chapter’s named failure modes.
Plan / apply, CI bot and state
Built in
In dbt these are assembled: Chapter 14 for CI, Chapter 15 for scheduling, state artifacts you manage yourself.
Every row is attributed to SQLMesh’s own documentation, deliberately. And note what is absent: no performance number and no cost number, for either tool. The circulating "≈9× faster / 117× cheaper" figures are Tobiko-authored; dbt Labs’ counterpart 29% figure is a dbt Labs blog post; no independent benchmark exists. Chapter 16 established the discipline this table follows: no invented figure where none is independently sourceable.
Where dbt is ahead, with the one clean sourced number
Stars measure attention, not quality. Use the ratio, never "10× more popular", and never a hiring-pool number — those are unsourceable.
Package hub
A formal Fusion-compatibility badge, and first-party packages actively updated for v2
dbt-utils declares require-dbt-version [">=1.3.0", "<3.0.0"] and shipped v1.4.1 on 2026-06-28, so the bound admits 2.0.0. A bound admitting a version is not a vendor certification.
dbt-project compatibility, from the other side
sqlmesh init -t dbt reads an existing dbt project
Real, but not free. SQLMesh documents gaps: debug and graph.nodes.values are unsupported, snapshots need invalidate_hard_deletes: True, and — verbatim — "dbt’s recommended incremental logic is not compatible with SQLMesh".
Platform breadth
dbt platform ships CI, scheduling, catalog, cost insights and a semantic layer as one product
Breadth is a commercial argument, and the tiers matter: column-level lineage in dbt Catalog is Enterprise / Enterprise+ only.
Common mistake
Quoting a tool-comparison performance or cost multiple in a design review. Someone asks who ran the benchmark, and the answer is "the vendor who wins it". You have spent your credibility on the one claim in the room that nobody can defend, and every real argument you make afterwards is discounted. Run your own workload instead — that number is true for you, which is the only place it needs to be true.
Framing the choice as dbt versus SQLMesh in front of a decision-maker. Since 2026-06-01 both sit under one corporate parent, and since 2026-03-25 the SQLMesh OSS framework sits under the Linux Foundation. The framing dates you, and it hides the question that actually matters: which of these fits the way this team already works, and what does the migration between them cost in the specific places their documentation says it is not compatible.
Better habit
Date-stamp every claim in your own notes: the sentence, the source, and the day you read it. It costs six words and it is the only defence against expiry.
When you catch an expired claim in yourself, go and look at where you learned it — that source is probably still feeding you, and one expired claim is rarely alone.
Argue capability against your own workload, never against a vendor multiple. "We have 40 development environments and promotion costs us four hours" is an argument; "9× faster" is a slide.
The one number in this section, and why only one
GitHub stars are printed because they have a single verifiable source and a read date: 13,575 against 3,233 on 2026-08-04, roughly 4.2×. Everything else people quote about these two tools — throughput multiples, cost savings, hiring-pool sizes — has either a vendor behind it or nothing behind it. This chapter prints no performance or cost number for either tool, on purpose, and says so out loud so the omission reads as a decision rather than an oversight.
"Have you looked at SQLMesh? Why did you stay on dbt?"
The weak answer is a feature list in one direction. The strong answer starts with the org fact — Fivetran acquired Tobiko Data in September 2025, contributed SQLMesh to the Linux Foundation in March 2026, and completed the dbt Labs merger in June 2026, so both tools now share a parent. Then name two things SQLMesh genuinely does better with attribution: virtual data environments as a pointer swap, and automatic breaking / non-breaking change categorisation, which dbt has no equivalent for. Then give the honest reason a team stays: the migration is not free, and SQLMesh’s own documentation says dbt’s recommended incremental logic is not compatible. Refusing to quote a benchmark, and saying why, is the part interviewers remember.
Remember this
A staff engineer’s knowledge of this space has a half-life of about a year. Attach a date and a source to every claim before you repeat it — and when the claim is a performance or cost multiple with a vendor behind it, do not repeat it at all.
03 · Every deprecation warning becomes an error
What the Migration Actually Breaks
a behaviour flag you opted out of, a custom materialization, an unsupported adapter. Only the third bucket has schedule risk in it, and it is usually the smallest bucket and the whole project.
⏱ 6 min · Topic 3 of 9
A v2 migration is not an engine swap with a compatibility shim. It is a code-cleanup project wearing an engine-upgrade costume, and the ordering is fixed: get to 1.12 first, run dbt-autofix, validate with dbt parse --use-v2-parser, then dev, then staging, then production. Nobody jumps to a 2.0 alpha.
The preflight below runs two commands over Wavelength and produces nine findings. Sort each by what it costs you — that sorting is the actual migration skill.
Core mental model
Sort every migration finding into three buckets by cost, not by severity. AUTOFIXABLE — a command changes it and you read the diff. HAND EDIT — typing, plus occasionally a decision hiding inside the typing. ARCHITECTURAL — a decision with no mechanical answer: a behaviour flag you opted out of, a custom materialization, an unsupported adapter. Only the third bucket has schedule risk in it, and it is usually the smallest bucket and the whole project.
Why it matters
All deprecation warnings become hard errors on v2, which turns everything you have been "meaning to get to" into a gate. The single most expensive line in the upgrade guide is about behaviour flags: they are removed, and verbatim, "You can no longer opt out of them using flags: in your dbt_project.yml." Every flag you set to defer work is now that work, due. Meanwhile three of the nine findings in a realistic preflight come from a human reading the repository rather than from any command — including the one nothing reports at all, because a custom materialization causes the engine to silently skip static analysis without erroring or warning. Any readiness estimate produced only by tooling is missing exactly the findings that carry schedule risk.
dbt-autofix
A separate PyPI CLI, not a dbt subcommand: uvx dbt-autofix, v0.21.6 (2026-07-31), with subcommands deprecations, packages and jobs. It rewrites dbt project files. A --models flag in .github/workflows/ci.yml is not a dbt project file, so nobody automated that one for you.
dbt parse --use-v2-parser
The validation step, and the one the v1.12 upgrade guide documents as 5–10× faster than the 1.x parser. Run it as a non-blocking CI job long before you plan a migration: it costs nothing and produces a rolling compatibility report you can burn down.
dbt1700
The deprecation code for --partial-parse / --no-partial-parse being removed from Fusion jobs. Worth knowing by number because it is the one that shows up in job definitions rather than in your models.
Package compatibility, mechanically
A package is compatible if and only if its require-dbt-version range includes 2.0.0. dbt-utils declares [">=1.3.0", "<3.0.0"] and shipped v1.4.1 on 2026-06-28, so it qualifies. Upgrade with dbt deps --upgrade and commit package-lock.yml — the lock file is the artefact that makes the answer reproducible.
The CLI changes, split by how they failworked example
Shell
# ERROR on v2 — gone, not deprecated
dbt run --models tag:daily # --models / --model / -m all error
dbt ls --resource-type model # renamed to the plural --resource-types
# The replacements
dbt run --select tag:daily
dbt ls --resource-types model --output name
# Removed from Fusion jobs — deprecation code dbt1700
dbt build --partial-parse
dbt build --no-partial-parse
# These do not error and do not do anything — but they are not silent.
# The upgrade guide, verbatim: "the command will not error, but the
# flag will do nothing (and warn accordingly)."
dbt compile --print
dbt compile --printer-width 120
dbt source freshness --output freshness.json
The first group fails loudly and is a morning of find-and-replace. The last group is the one to worry about even though it warns: the flag does nothing and the command exits 0, and the warning it does emit lands next to a hundred others in a CI log nobody reads — close enough to silent to bite the same way, weeks later. Note the spelling change too — every detection command in this chapter uses 1.12’s --resource-type, and on v2 that becomes --resource-types.
Jinja: two changes, both of which live inside macrosworked example
Both changes are mechanical and both are in macros, which is exactly what makes them worth reviewing rather than sed-ing: a macro edit ships to every consumer at once and no single model’s diff shows it. That is the whole mechanism behind the "macros nobody can read" entry in the next section.
YAML: three ways a properties file stops parsingworked example
YAML
# 1. Standalone anchors must move under a new top-level `anchors:` key.
# A bare anchor at the top of a properties file no longer parses on v2.
anchors:
standard_tests: &standard_tests
- unique
- not_null
models:
- name: stg_episodes
columns:
- name: episode_id
data_tests: *standard_tests
# 2. Duplicate docs blocks are an error rather than last-one-wins:
# dbt found two docs with the same name: 'docs_block_title'
# Until today, half your column descriptions pointed at a block you
# did not mean — and nothing said so.
# 3. Conflicting package pins are an error:
# Cannot combine non-exact versions: =0.8.3 and =1.1.1
All three are parse-time on v2, which means the whole project stops rather than one node failing. Error strings are quoted from the upgrade guide (updated 2026-07-31), not captured from a run of this project.
What actually breaks, by category
Category
The change
What it means for your week
Deprecations
All deprecation warnings become hard errors
This is why the sequence is 1.12 first: 1.12 is where you can see them as warnings and still ship.
Behaviour flags
Removed, not toggleable — "You can no longer opt out of them using flags: in your dbt_project.yml"
Chapter 5 owns the flag that makes --warn-error gate a freshness WARN at all: require_all_warnings_handled_by_warn_error, true by default from 1.12. On v2 the opt-out under flags: is gone, so a project that had set it to false loses that escape hatch with the migration, and the warnings it was suppressing become the reader’s problem.
CLI
--models / --model / -m error; --resource-type becomes --resource-types; --partial-parse removed from Fusion jobs (dbt1700); --print, --printer-width and source freshness’s --output/-o do not error, do nothing, and warn
The loud ones cost a morning. The warned-but-effectively-silent flags cost a debugging session, weeks later, in someone else’s CI job whose warnings nobody reads.
Parse-time strictness
A nonexistent macro, a missing generic test in data_tests:, and {{ var('does_not_exist') }} all now fail at parse
Failures move earlier, which is a gift delivered as a wall: a project that parsed happily for two years may not parse at all on day one.
Jinja
Algebraic macro returns unsupported; config.get() and config.require() no longer read meta — use config.meta_get() and config.meta_require()
Mechanical edits in the highest-blast-radius files you own.
YAML
Standalone anchors move under a new top-level anchors: key; duplicate docs blocks error; conflicting package pins error
Parse-time, so the whole project stops rather than one node.
Run behaviour
dbt build now runs ALL unit tests first; a compile error no longer halts the run (unrelated nodes keep compiling); seed CSVs with trailing commas no longer create empty columns
The middle one changes what a red run looks like — you now get every compile error at once instead of the first.
Threads
Snowflake and Databricks auto-manage connection parallelism from platform limits and backpressure; threads only acts as a maximum connection cap if you set it, and Fusion is designed to run optimally left unconfigured. BigQuery and Redshift respect threads for rate/concurrency limits, where --threads 0 (or omitting it) lets Fusion dynamically optimize.
Your carefully tuned thread count may do nothing at all on Snowflake or Databricks unless you deliberately want a ceiling. Re-read the upgrade guide’s per-adapter table before you conclude the engine is slower.
Packages
Compatible iff require-dbt-version includes 2.0.0. dbt-utils is: [">=1.3.0", "<3.0.0"], v1.4.1, 2026-06-28
dbt deps --upgrade, then commit package-lock.yml. Chapter 18 owns why the lock file is the artefact that matters.
Documented as NOT supported
The programmatic / Python invocation API; SQLFluff linting in dbt platform CI (partial); model-level notifications; anything depending on exact v1 log formatting; some materialization configs
Read this list before you budget the migration. Two of these are load-bearing in more projects than their owners realise — log-format parsing especially.
Sourced from /docs/dbt-versions/core-upgrade/upgrading-to-v2 (updated 2026-07-31). Nothing here was captured from a run of this project.
Common mistake
Estimating the migration from the count of findings a tool reports. A nine-item list where two are free, four are typing and three are decisions is not a nine-item list. And three of a realistic nine come from a human reading the repository — including the custom materialization, which no command reports at all, because the engine silently skips analysis for it. Every estimate produced only by tooling under-counts exactly the findings that carry schedule risk.
Planning the migration as "upgrade the engine, then fix what breaks". You lose the ability to attribute a difference. If the engine changed and your code changed in the same window, a wrong number has two possible causes and you will spend a week eliminating one of them. Go to 1.12, clear every deprecation warning while it is still only a warning, and only then change engines — so that when something moves, exactly one thing could have moved it.
Better habit
Add dbt parse --use-v2-parser as a NON-BLOCKING CI job today, whatever your migration plans are. It changes nothing, blocks nothing, and turns a future migration into a burndown list you have already been watching for months.
Read the dbt-autofix diff rather than trusting it. It is a rewrite of your project files; treat it exactly like any other machine-generated PR.
Before budgeting, grep your repository for the flags that only warn instead of erroring — --print, --printer-width and source freshness’s --output/-o. A flag that keeps "running" while quietly doing nothing is worth more of your attention than one that errors and stops you cold.
dbt Labs publish a migration sequence — and it assumes an account
The prepare-fusion-upgrade guide (updated 2026-07-31) gives a real order: enable readiness, then move to the Latest release track in order — personal override, development, staging (run for several days to validate), production (monitor the first few runs), then jobs — then branch, run autofix, fix the residue by hand, open a PR, validate and upgrade packages, and finish with per-job eligibility triage. Mirror that order. But state the caveat honestly whenever you cite it: it is written for dbt platform customers and assumes an account and a developer licence. There is no equivalent published sequence for a pure dbt Core shop running its own CI, which is most readers of this chapter.
The finding no command reports
A custom materialization keeps working on v2 — the engine renders your Jinja to DDL as before. What it cannot do is know whether your materialization changes columns, so it silently skips static analysis for that node. It does not error and it does not warn. The failure mode is believing you migrated: green build, no findings, and a subtree with no column-level lineage and degraded DAG precision. Pre-flight custom materializations by hand, because nothing else will.
"What actually breaks when you upgrade a large dbt project?"
Do not recite the changelog. Give the shape: all deprecation warnings become hard errors, behaviour flags are removed so previously deferred work comes due, three parse-time strictness rules fail whole projects that used to parse, and a handful of CLI flags now silently do nothing rather than erroring. Then give the sentence that shows you have sized one: at 900 models the cost is not the engine, it is the long tail — this is a code-cleanup project wearing an engine-upgrade costume, and it should be budgeted as one.
Remember this
Go to 1.12 first, clear the deprecations while they are still warnings, validate with dbt parse --use-v2-parser, and sort what is left by cost rather than severity. Two findings are free, four are typing, and three are the migration.
04 · Detection, cost, and the counter-case
Nine Ways a Project Goes Wrong
what it is, why it breaks, what it costs, the correct version, and WHEN IT IS ACTUALLY RIGHT. Every one of these nine has a real counter-case. A catalogue that omits the exception gets discarded by exactly the senior reader it was written for, because they can think of the exception in four seconds and now distrust the other eight entries.
⏱ 8 min · Topic 4 of 9
Seven of the nine below were demonstrated somewhere in the previous twenty-one chapters, and Chapter 12 published a promise about this section: revisit each as a review-time check and point back at the widgets rather than re-running the demonstrations. So the centre of gravity here is different — detection signal, cost over two years, and the comment you would actually leave in a pull request.
The recall widget shows you a consequence and asks which chapter already owns the fix. Every correct attribution writes one line into the checklist you keep at the end.
The detection commands — every one of them, in one placeworked example
Shell
# 1 A snapshot with a model ancestor. Any output at all is the finding.
dbt ls --select +snap_show_tier --resource-type model --output name
# 2 Source fan-out: exactly one direct stg_ child per source table.
dbt ls --select source:wavelength_app+1 --resource-type model --output name
# 3 A star directly on a ref()/source(). Does NOT match "select * from renamed".
rg -n --glob '*.sql' 'select\s+\*\s+from\s+\{\{\s*(ref|source)' models/
# 4 Incremental by habit. Then ask, per hit: does a full refresh fit the window?
# does it declare a unique_key? is on_schema_change still ignore?
dbt ls --select config.materialized:incremental --resource-type model --output name
# 5 Test COMPOSITION, not coverage.
jq -r '.nodes[] | select(.resource_type=="test") | .test_metadata.name' \
target/manifest.json | sort | uniq -c | sort -rn
# 6 Models with neither a group nor a meta.owner.
jq -r '.nodes[] | select(.resource_type=="model")
| select((.group == null) and (.config.meta.owner == null)) | .name' \
target/manifest.json
# 7 One- and two-model directories — then ask who actually wrote them.
dbt ls --resource-type model --output path | xargs -n1 dirname | sort | uniq -c | sort -n
git log --format='%an' -- models/marts/finance | sort -u
# 8 Macro expansion ratio. NOT free: compiled_code needs a compile, and
# dbt compile connects to the warehouse (chapter 6).
jq -r '.nodes[] | select(.resource_type=="model")
| [.name, (.raw_code|split("\n")|length), (.compiled_code|split("\n")|length)]
| @tsv' target/manifest.json
Checks 1, 2, 4, 5, 6 and 7 read a PARSED manifest — free, offline, no warehouse. Check 8 does not, and saying so is the difference between a preflight people keep and one they turn off. dbt ls includes tests by default and excludes analyses, which is why --resource-type and --output name are typed every single time (Chapter 11’s discipline). On v2 that flag becomes --resource-types.
Tests: the ladder up from dbt Labs’ own floorworked example
YAML
models:
- name: fct_listens
columns:
- name: listen_id
data_tests:
- unique # dbt Labs' baseline, and it is right
- not_null # this is the FLOOR, not the ceiling
- name: pct_completed
data_tests:
# A BOUND — is this number inside the range it can possibly take?
- dbt_utils.accepted_range:
arguments:
min_value: 0
max_value: 100
config:
severity: error
data_tests:
# A RECONCILIATION — do two columns agree with each other?
# This is the one that catches ep_1008, where the row count never moves.
- dbt_utils.expression_is_true:
arguments:
expression: "ms_played <= duration_ms * 1.05"
config:
severity: error
Chapter 7 owns the bound-versus-reconciliation distinction and the 1.12 YAML shape — arguments under arguments:, options under config:. Note what dbt Labs do and do not say: they state that every model should have a primary key tested for uniqueness and not-null. They have no published position that a PK-only suite is insufficient. The floor is cited; "coverage theatre" is this knowledge base’s judgement, and it is labelled as such.
Migrating a misplaced snapshot — you cannot simply repoint relation:worked example
YAML
snapshots:
# The NEW node, against the source. Its history starts on cutover day.
# That is the hard floor and no amount of care moves it.
- name: snap_shows
relation: source('wavelength_app', 'shows')
config:
unique_key: show_id
strategy: check
check_cols: ['tier']
# The OLD one: frozen, documented, never rebuilt. Not repointed.
# Repointing keeps the same node and swaps transformed history for raw rows,
# which re-versions every row on the next run, dated the deploy.
- name: legacy_snap_show_tier
relation: ref('stg_shows')
config:
enabled: false
Run both in parallel until the new one has the history you need, then read from the new one and keep the old relation as a frozen artefact. dbt snapshot has no --full-refresh, and dbt build --full-refresh is a silent no-op on a snapshot node because the materialization hard-codes full_refresh_mode=False — so there is no rebuild that fixes the past. Chapter 10 published the punchline: a re-snapshot does an initial capture, one row per live key, and the table looks perfectly healthy.
Folder sprawl — the corrected version: a folder that earns its keepworked example
YAML
# dbt_project.yml — this folder exists to BUY something, not out of habit
models:
wavelength:
marts:
finance: # a config boundary, not organisational preference
+schema: finance
+group: finance
# CODEOWNERS — the second half of "this is a real boundary"
/models/marts/finance/ @wavelength/finance-team
Three models under models/marts/finance/ is folder sprawl if nothing points at the folder. It is a config boundary the moment +schema and +group depend on the path, and a real one the moment a CODEOWNERS line names a second team behind it. Detection asked whether a directory has a real second team behind it — this is what "yes" looks like in the two files that actually decide it, not a naming convention.
Macros nobody can read — the corrected version: verifiable, not readableworked example
Shell
# 1. Freeze the compiled output as a fixture. The macro's unreadability
# stops being a review problem once its OUTPUT is pinned and diffed.
dbt compile --select fct_revenue_share
cp target/compiled/wavelength/models/marts/fct_revenue_share.sql \
tests/fixtures/fct_revenue_share.sql.frozen
# 2. CI re-compiles and diffs against the frozen fixture on every PR
# that touches the macro or the model calling it — a change in the
# diff is exactly the review prompt: did you mean this?
dbt compile --select fct_revenue_share
diff target/compiled/wavelength/models/marts/fct_revenue_share.sql \
tests/fixtures/fct_revenue_share.sql.frozen
Complexity is not the problem; unverifiable complexity is. Pair the fixture diff above with a unit test on the model that calls the macro — Chapter 8’s given/expect over rows a reviewer chose by hand — and a twelve-line adapter.dispatch macro nobody can read at a glance becomes checkable without becoming readable. Neither step is the fix this catalogue argued for at readability; both are the fix it argued for at verifiability, which is the bar that actually matters.
Core mental model
Every entry has five beats, and the fifth is what keeps the catalogue from being dogma: what it is, why it breaks, what it costs, the correct version, and WHEN IT IS ACTUALLY RIGHT. Every one of these nine has a real counter-case. A catalogue that omits the exception gets discarded by exactly the senior reader it was written for, because they can think of the exception in four seconds and now distrust the other eight entries.
Why it matters
An anti-pattern catalogue is only useful to somebody who has already felt the failure — which is why this arrives at chapter 22 rather than chapter 2. But four of the nine are NOT backed by dbt Labs guidance, and a catalogue that presents all nine with the same authority is teaching dogma. Three are backed strongly, two partially, and four are this knowledge base's engineering judgement, labelled as such in the backing callout below. The distinction matters in a real review: "dbt Labs' guide says staging is the only place the source macro appears" ends an argument; "I think this macro is too complex" starts one, and should.
Detection signal
A one-line command whose output IS the finding, with no interpretation required. The best in this catalogue is dbt ls --select +<snapshot> --resource-type model --output name: any output at all means a snapshot has a model ancestor. Zero false positives, no warehouse round trip, and it catches the most expensive mistake in the list.
Test composition
The discriminating measure that coverage percentage hides: the unique + not_null share of all tests, and tests per staging model against tests per mart. A project can be 100% green on the industry’s canonical coverage rule and carry zero semantic coverage.
A folder boundary is a config boundary
dbt_project.yml resolves config by directory path, so a pull request that "just moves files" silently changes +materialized, +schema and +tags inheritance, and dbt says nothing. Second-order: --select path: and --select tag: selectors in CI break silently when a file moves, so a slim-CI selector quietly stops covering a model.
The expansion ratio
Raw lines against compiled lines for a model, read from a compiled manifest. A useful triage signal for macro complexity — with an honest cost attached: compiled_code requires a compile, and Chapter 6 established that dbt compile connects to the warehouse and runs queries. It is not free, and it is the one check on this list that is not.
dbt-project-evaluator
v1.3.4 (2026-07-30), 29 rules across modeling, testing, documentation, structure, performance and governance — the automatable half of this catalogue. Real defaults worth knowing: chained_views_threshold is 5 (4 on Athena, Trino and ClickHouse) and models_fanout_threshold is 3. Both are package defaults and both are configurable. Its require-dbt-version is [">=1.10.6", "<3.0.0"], so the bound admits v2 — which is not the same as dbt Labs certifying it on Fusion.
Nine anti-patterns: the signal that finds it, and what it costs by year two
Anti-pattern
Detection signal
The cost that shows up later
No staging layer
dbt ls --select source:<src>+1 --resource-type model --output name — more than one stg_ child is source fan-out. Evaluator: fct_source_fanout, fct_direct_join_to_source.
The project’s definition of a column becomes the union of everywhere it is written. A source rename needs N edits and the compiler catches none, because source() resolves to a relation, not a schema. Two consumers cast differently and the project silently holds two answers.
800-line models
Line count TRIGGERS a human read; it is never the verdict. The verdict is grain-change count against whether the model has any uniqueness test.
Each join, union all, group by and select distinct changes what one row means. Inside one file none of those points is a relation, so none can carry a test and no reviewer can bisect a wrong number to a step. At 3am it is one red box with no partial state.
Tests only on primary keys
Test COMPOSITION over test count: jq the manifest for test_metadata.name, and read the unique + not_null share.
This knowledge base has three published defects with a row-count delta of ZERO: Chapter 7’s ep_1008 at 1,980 ms (unique passes, not-null passes, accepted-values passes, and the value is 1,000× wrong), Chapter 9’s pct_completed reading 93,434.3 instead of 93.4, and Chapter 12’s fan-out at 1,000 in, 1,000 out, 11.63% wrong.
Incremental everywhere
dbt ls --select config.materialized:incremental --resource-type model --output name, then three questions per hit. Reviewer-grade signal: the SHARE of models that are incremental.
Three costs, and most people name only the third. Correctness: without the event-time column and its lateness distribution you cannot size a lookback, so the model ships with either silent loss or duplicate generation baked in. Silent drift: on_schema_change defaults to ignore — add is silent, remove fails, no values are backfilled. Cost: Chapter 16 priced it.
Snapshotting a model
dbt ls --select +<snapshot> --resource-type model --output name. Any output at all is the finding. One line, zero false positives, no warehouse round trip.
A check_cols staging edit re-versions every row on one run, dated the deploy — that boundary is an artefact, not an event. dbt_valid_from permanently mixes business and deploy timestamps, so it can never be used as an event time again. A staging filter reads as a hard delete. Unrecoverable, because the source overwrites in place.
select * chains between models
rg -n --glob '*.sql' 'select\\s+\\*\\s+from\\s+\\{\\{\\s*(ref|source)' models/ — matches the star ON a ref()/source() and not the sanctioned final CTE.
Two symmetric breakages: a new upstream column arrives in a dashboard with nobody’s approval (make it a PII column and it is a compliance incident), and a dropped one breaks a downstream model at run time in production, with a warehouse error rather than a dbt one. Plus lost projection pushdown and degraded lineage on v2 / Fusion.
Folder sprawl
dbt ls --resource-type model --output path | xargs -n1 dirname | sort | uniq -c | sort -n for 1–2 model directories, then git log --format='%an' -- <dir> | sort -u. The second half is in no linter.
A folder boundary is a CONFIG boundary: dbt_project.yml resolves config by path, so a "just moving files" PR silently changes +materialized, +schema and +tags inheritance. And --select path: / tag: selectors in CI break silently, so a slim-CI selector quietly stops covering a model.
Macros nobody can read
The expansion ratio, raw lines against compiled lines — plus the question no tool asks: can you predict the compiled output before you look at it?
A 12-line model compiling to 400 lines is a model no reviewer read, so approval is a formality. A bug lives in the macro, so it ships to every consumer at once and no single model’s diff shows it. And an {% if execute %}-guarded run_query() macro makes COMPILATION a warehouse round trip — Chapter 6’s correction, which is why the "free CI lint" reading compiled_code is not free.
No ownership
jq for models with neither a group nor a config.meta.owner, and exposures with no owner email. Then the non-linter check.
dbt has four unconnected ownership surfaces — groups with an owner, config.meta, an exposure’s owner, and CODEOWNERS in git — and cross-checks none of them against any other. A model can be silent on all four and still build, ship, and be read by a dashboard.
The prior demonstrations are deliberately not re-run: Chapter 5’s staging-boundary lab, Chapter 12’s split ledger and decomposition stepper over a 437-line report, Chapter 7’s coverage map, Chapter 9’s readiness assembler, Chapter 10’s placement lab and irreversibility bench, Chapter 12’s naming charter, Chapter 6’s review bench, and Chapter 13’s access and groups material. Go and re-run them there.
And when each one is actually right
Anti-pattern
The real counter-case
No staging layer
A source that is already a contracted public model from another project has had its staging done for it — that is what Chapter 19’s cross-project boundary is for. And a genuine one-consumer-forever source is fine, except that "exactly one consumer forever" is a PREDICTION, and the second consumer is what makes it expensive.
800-line models
A genuinely irreducible pivot with a CONSTANT grain and one output contract can be long, dense and correct. Machine-generated SQL too: long compiled, short raw. A model with six grain changes and zero grain tests is the finding; a model with 800 lines and one grain is not.
Tests only on primary keys
It is the right FIRST state. Chapter 7’s coverage map starts exactly where most real projects start, with 8 of 11 models carrying a uniqueness badge and three carrying nothing. This is not a bad first state. It is a bad steady state.
Incremental everywhere
Chapter 9’s gate met as a conjunction — and the honest reverse: on BigQuery or Databricks with real partition pruning, incremental is correct and cheap at a much smaller table than folklore suggests. The gate is the conjunction, not the row count.
Snapshotting a model
Three cases. The documented ephemeral prep model that only selects, renames and filters — with Chapter 10’s caveat that adding a case expression converts it into a staging model and flips the answer. A source you genuinely cannot declare as a source(). And snapshotting a DERIVED decision, such as a computed tier or risk score, where your output IS the versioned thing — legitimate, but then the code version is part of the key and the description has to say so.
select * chains
dbt’s own style guide PRESCRIBES select * from final as the last line of a model, so the objection is scoped to a star directly on a ref() or source(), propagating between models. Also fine: a _v2 passthrough during a deprecation window, and a union all shim. The rule that survives: select * is fine wherever the column list is fixed by something else — a CTE you wrote above it, an enforced contract, or a versioned interface.
Folder sprawl
Genuine multi-team ownership with a distinct CODEOWNERS entry — the trigger is OWNERSHIP, not model count. Or a folder created deliberately AS a config boundary: if models/marts/finance/ exists to give +schema: finance, it earns its keep at three models, because it buys something you can name.
Macros nobody can read
adapter.dispatch macros are SUPPOSED to be hard to read and are still right — cross-warehouse correctness costs readability and that is a fair trade. And the reframing that matters: a macro’s readability budget is set by how a reviewer can VERIFY it, not by how it looks. An unreadable macro with a unit test and a frozen compiled-output fixture is safer than a readable one with neither. Complexity is not the problem; unverifiable complexity is.
No ownership
A genuinely one-person project owes nobody a group. Chapter 13’s honest trigger is the first consumer outside the team that built it. And note two things Chapter 13 already settled: access is a fence around ref(), not around SELECT — it stops nobody with warehouse credentials — and protected is the default and buys almost nothing on a single project, so "add access levels to everything" is itself the anti-pattern.
Common mistake
Treating dbt-project-evaluator’s green board as evidence the project is well tested. Its fct_missing_primary_key_tests rule enforces exactly the floor — a tested primary key on every model. So a project can be 100% green on the industry’s canonical coverage rule and carry zero semantic coverage: no bound on any number, no reconciliation between any two columns. All three of this knowledge base’s published zero-row-delta defects live comfortably underneath a fully green evaluator run.
Repointing a misplaced snapshot’s relation: from the model to the source, in place. Same node, raw rows where transformed history used to be: every row re-versions on the next run, dated the deploy, and now the history contains a boundary that corresponds to nothing that happened in the business. Build a NEW snapshot against the source, run it in parallel, freeze the old relation as legacy_, document it and never rebuild it — and accept the hard floor, which is that the new snapshot’s history starts on cutover day.
Better habit
Write the detection command before you write the checklist item. If you cannot express the check as a command or as a question only a human can answer, it is not a check — it is a preference, and it will get ticked without being read.
When you flag one of these in review, name the beat you are on: this is what it costs by year two, and here is when it would actually be right. A finding delivered with its counter-case gets fixed; a finding delivered as a rule gets argued with.
Ask the three-people question about your own top three marts: take the marts with the most downstream exposures and ask three different people who owns each. Three answers means the metadata is decorative, whatever the manifest says.
Four of these nine are NOT backed by dbt Labs — and this chapter says which
Backed strongly: no staging layer (dbt Labs’ own guide — staging models are the only place the source macro is used, and joins are almost always a bad idea there); 800-line models (the marts guide on models spanning more than four or five concepts, plus the workflows guide’s three break-up triggers); incremental everywhere (the view → table → incremental ladder, verbatim). Partially backed: tests-only-on-PKs — dbt Labs’ baseline IS the primary-key test, and they have no published position that PK-only is insufficient, so the floor is cited and "coverage theatre" is owned as this knowledge base’s judgement; folder sprawl — the marts guide backs not over-optimising early, under ten marts, but also endorses grouping by department, so the objection is scoped to folders matching neither a source nor a real business area and carrying no config or ownership meaning. NOT backed, and owned as engineering judgement: snapshot-the-source (the historic "snapshot query best practices" block is no longer on the current docs page, and every quote of it is secondary); select * chains (dbt’s own style guide prescribes select * from final, so the objection must be scoped to a star on a ref() or source()); and macro complexity (dbt’s entire Jinja style guide is four cosmetic rules — there is no macro-complexity guidance to cite).
Chapter 7’s map, and the class of defect a primary-key test structurally cannot see
Chapter 7’s coverage map starts where most real projects start: 8 of 11 models carried a uniqueness badge and three carried nothing at all. The point here is not to re-run it — it is the inversion. Three defects in this knowledge base have a row-count delta of exactly zero: ep_1008 at 1,980 ms, where unique, not-null and accepted-values all pass and the value is 1,000× wrong; pct_completed reading 93,434.3 where the truth is 93.4; and Chapter 12’s fan-out at 1,000 rows in, 1,000 rows out and 11.63% wrong. A row-count reconcile is not evidence, and a checklist item that says "check the row count" contradicts three chapters at once.
The automatable half already exists — use it, and know its limits
dbt-project-evaluator v1.3.4 ships 29 rules across modeling, testing, documentation, structure, performance and governance, including fct_source_fanout, fct_direct_join_to_source and fct_missing_primary_key_tests. dbt-checkpoint provides community pre-commit hooks for the same territory. Two honest limits: printed thresholds are package defaults and configurable — chained_views_threshold is 5, or 4 on Athena, Trino and ClickHouse; models_fanout_threshold is 3 — and its require-dbt-version bound of [">=1.10.6", "<3.0.0"] admits v2 without dbt Labs certifying it on Fusion.
"What do you look for when reviewing someone else’s dbt PR?"
A list of nine anti-patterns is a memorised answer and interviewers can hear it. The strong answer names the shape instead: I split the review into what a machine can check and what only a person can. The machine checks snapshot ancestry, source fan-out, stars on refs, ownership metadata and incremental config — all off a parsed manifest, offline. What I read for is intent: does this change what one row means, what is the invariant that should hold before and after, did anyone see the new test fail, and is there anything in the blast radius that cannot be rebuilt. Then close with the asymmetry: I try just as hard not to flag lines that are fine, because a reviewer who flags everything teaches their team to ignore all of it.
Remember this
Detection signal, cost by year two, correct version, and the case where it is actually right. Nine entries, four of them this knowledge base’s judgement rather than dbt Labs’ guidance — and the catalogue says which is which, because a rule you cannot attribute is a rule somebody will overrule.
05 · A source release, and two different Wednesdays
The Column Nobody Selected
is the column list fixed by something else? A CTE you wrote above it, an enforced contract, or a versioned interface all fix it — and dbt's own style guide prescribes select * from final for exactly that reason. A star directly on a ref() or a source() fixes nothing, and that is the only shape this rule is about.
⏱ 5 min · Topic 5 of 9
Of the nine entries, this is one of only two with a genuine demonstration gap — Chapter 12 argued the rule but never showed rows moving through it. So this section shows it on Chapter 7's seeded episodes: nine physical rows, eight distinct episode_id.
On 2026-08-04 at 09:00 UTC the source team shipped. They added a column, and in a separate release they renamed one away. Nothing in the repository changed on either day.
models/marts/mart_episode_catalogue.sql — the star chain, exactly as writtenworked example
SQL
Input data
wavelength_app.episodes — before the 09:00 UTC release9 rows
episode_id
show_id
duration_ms
status
published_at
ep_1001
sh_11
2400000
published
2026-07-18
ep_1002
sh_11
1800000
published
2026-07-20
ep_1003
sh_11
2100000
published
2026-07-22
ep_1004
sh_11
1620000
PUBLISHED
2026-07-24
ep_1005
sh_11
2760000
published
2026-07-26
ep_1005
sh_11
2760000
published
2026-07-26
ep_1006
sh_11
1440000
published
2026-07-28
ep_1007
sh_11
NULL
published
2026-07-30
ep_1008
sh_11
1980
published
2026-08-01
Chapter 7’s seeded rows, unchanged: 9 physical rows for 8 distinct episode_id. ep_1005 appears twice — that chapter’s known duplicate, already recorded, not a new finding. ep_1008’s 1,980 ms is its known unit drift.
wavelength_app.episodes — after the release, with content_rating added9 rows
episode_id
duration_ms
status
published_at
content_rating
ep_1001
2400000
published
2026-07-18
clean
ep_1002
1800000
published
2026-07-20
clean
ep_1003
2100000
published
2026-07-22
explicit
ep_1004
1620000
PUBLISHED
2026-07-24
clean
ep_1005
2760000
published
2026-07-26
clean
ep_1005
2760000
published
2026-07-26
clean
ep_1006
1440000
published
2026-07-28
explicit
ep_1007
NULL
published
2026-07-30
unrated
ep_1008
1980
published
2026-08-01
clean
show_id is omitted from this second preview only for width — it is unchanged and still sh_11 on every row. One new column, no new rows, no schema change on your side.
mart_episode_catalogue after the next scheduled run — 9 rows, 7 columns
episode_id
show_id
duration_ms
status
published_at
content_rating
duration_minutes
ep_1001
sh_11
2400000
published
2026-07-18
clean
40
ep_1002
sh_11
1800000
published
2026-07-20
clean
30
ep_1003
sh_11
2100000
published
2026-07-22
explicit
35
ep_1004
sh_11
1620000
PUBLISHED
2026-07-24
clean
27
ep_1005
sh_11
2760000
published
2026-07-26
clean
46
ep_1005
sh_11
2760000
published
2026-07-26
clean
46
ep_1006
sh_11
1440000
published
2026-07-28
explicit
24
ep_1007
sh_11
NULL
published
2026-07-30
unrated
NULL
ep_1008
sh_11
1980
published
2026-08-01
clean
0.03
duration_minutes is round(duration_ms / 60000.0, 2): 2,400,000 → 40.00, 1,620,000 → 27.00, and 1,980 → 0.03, which is exactly the figure Chapter 7 published for ep_1008. ep_1007’s NULL duration produces a NULL. The column that matters is the sixth: content_rating is in a mart that no model ever named, and the Content Team Weekly exposure gains a column tomorrow morning that nobody approved.
Three models, three stars, one added column. Every diff in this repository on 2026-08-04 is empty — nobody edited anything. lower(status) is deliberate: ep_1004 carries PUBLISHED in upper case, which is Chapter 7’s known casing defect, and the filter keeps it rather than silently dropping the row.
The contract, which is the structural fix rather than the disciplinary oneworked example
YAML
models:
- name: mart_episode_catalogue
config:
contract:
enforced: true
columns:
- name: episode_id
data_type: varchar
- name: show_id
data_type: varchar
- name: duration_ms
data_type: number
- name: status
data_type: varchar
- name: published_at
data_type: date
- name: duration_minutes
data_type: number
dbt preflights the columns the model returns against the declared set, and an EXTRA column fails the build — which is precisely why a contract, and not discipline, is the fix for a star chain. Chapter 13 built this enforcement for a different reason and it happens to be the right mechanism here. The compilation error names content_rating with mismatch_reason "missing in contract", and the build stops before anything reaches a dashboard.
Core mental model
The failure is not the star. It is the absence of an interface between models. Ask of any star: is the column list fixed by something else? A CTE you wrote above it, an enforced contract, or a versioned interface all fix it — and dbt's own style guide prescribes select * from final for exactly that reason. A star directly on a ref() or a source() fixes nothing, and that is the only shape this rule is about.
Why it matters
The two breakages are symmetric and only one of them is loud. On the quiet side, a column arrives in a mart nobody selected it into: change content_rating to a PII column and the same mechanism is a compliance incident rather than an untidy dashboard. On the loud side, a dropped column breaks a model at run time, in production, with a warehouse error rather than a dbt one — and under a star chain it breaks three layers away from the change, in a mart, after two models have already built successfully. Under explicit projection the same drop fails in stg_episodes, the one model whose job is to know what this source looks like, with nothing downstream even attempted.
Where the drop lands
Under a star chain, at the first model that names the column explicitly — here the mart, three layers from the change, after two successful builds. Under explicit projection, at stg_episodes, with int and mart both SKIPped. Same defect, same day, two very different mornings.
The sanctioned pattern, verbatim
Chapter 12: "The sanctioned staging pattern ends with select * from renamed — one final CTE, projecting an explicitly named column list assembled above it. That is not the same thing as a select * CHAIN across layers." If an interviewer asks whether select * is acceptable in dbt, that distinction is the answer.
Contract enforcement is symmetric
An enforced contract fails on EXTRA columns as well as missing ones. That symmetry is what makes it the structural fix here: an upstream addition becomes loud at build time instead of silent at dashboard time.
The secondary cost
A star defeats projection pushdown, so the warehouse reads columns nobody wants, and it degrades column-level lineage on v2 and Fusion — which is where column-level lineage exists at all. Both are real; neither is the reason to fix it.
Common mistake
Treating explicit projection as a notification mechanism. It stops the arrival and tells you nothing. content_rating still exists upstream, and you still find out about it by reading the source team’s release notes or by opening the table. Explicit projection buys a boundary; source freshness tells you a table is LATE, never that it is DIFFERENT. If you need to know when a source gains a column, that is a monitor you write, not a property you inherit.
Reading "never select *" out of this section. You will flag the last line of every well-written model in your project and lose the argument, because dbt’s own style guide prescribes exactly that line. Worse, the next time you flag a real star on a ref() the author will discount it, because you have already taught them the rule is arbitrary. Scope the objection precisely: a star directly on a ref() or a source(), propagating between models.
Better habit
When you add a model that reads another model, write the column list. It is thirty seconds and it converts a class of silent production failure into a compile-time one in the file you were already editing.
Put the contract on the models that something outside your team reads, and only those. A contract on an internal model is a promise you make to yourself — Chapter 13’s framing, and it is still right here.
When a source team announces a change, ask two questions rather than one: what did you add, and what did you take away. The second is the one that pages somebody.
This section deliberately does not re-derive an impact traversal
Chapter 11 owns the impact question and demonstrated it on listener_timezone. That traversal is cited here rather than repeated, and this section uses a fresh column — a content rating — so the two demonstrations do not collide. When you need to answer "who reads this column", go there; when you need to stop a column arriving in the first place, stay here.
A star chain does not prevent the failure — it relocates it
Under the star chain, stg_episodes and int_episode_catalogue both build successfully carrying a shape nothing downstream can use, and the failure surfaces in the mart, at run time, in production. Two layers of green build in front of a red one is worse than a red one at the boundary, because it looks like the mart is broken when the source is.
"Is select * acceptable in dbt?"
Answer with the distinction rather than a position. select * from final as the last line of a model is prescribed by dbt’s own style guide: one final CTE projecting a column list assembled explicitly above it. A star directly on a ref() or a source(), propagating between models, is a different thing entirely — it is the absence of an interface, and it produces two symmetric failures: a new upstream column arriving in a dashboard nobody approved, and a dropped column breaking downstream at run time with a warehouse error. Then give the structural fix rather than the disciplinary one: an enforced contract fails on extra columns too, so the addition becomes loud at build time.
Remember this
Nine rows, one added column, three empty diffs — and content_rating reaches a mart nobody selected it into. select * is fine wherever the column list is fixed by something else; directly on a ref() it is fixed by nothing, and the source team decides what your dashboard shows.
06 · Green build, approved, and wrong
The Pull Request That Passed CI
intended grain, whether a number is published anywhere a human looks, whether the backfill is affordable tonight, whether the blast radius contains something unrebuildable, whether the owner is reachable, whether the name still tells the truth. Everything you read for lives in pile two.
⏱ 4 min · Topic 6 of 9
A teammate in their second week opens PR #412: three files, thirteen added lines, and a completely green build. Five nodes, five passes, zero errors. Everything wrong with it is invisible to every automated gate this project has.
Click every line you would block on, then submit. You are scored on what you caught AND on what you flagged that was fine.
The six mechanical checks, as a preflight that runs before a human opens the diffworked example
Shell
#!/usr/bin/env bash
set -euo pipefail
# Everything below reads a PARSED manifest. Free, offline, no warehouse.
dbt parse -q
fail=0
note() { echo "PREFLIGHT: $*"; fail=1; }
# A1 No snapshot has a model ancestor.
for snap in $(dbt ls --resource-type snapshot --output name); do
anc=$(dbt ls --select "+${snap}" --resource-type model --output name || true)
[ -n "$anc" ] && note "A1 ${snap} has model ancestors: ${anc}"
done
# A2 Every source has exactly one direct stg_ child.
for src in $(dbt ls --resource-type source --output name); do
n=$(dbt ls --select "source:${src}+1" --resource-type model --output name | wc -l)
[ "$n" -eq 1 ] || note "A2 ${src} has ${n} direct model children"
done
# A3 No select * directly on a ref()/source().
rg -q --glob '*.sql' 'select\s+\*\s+from\s+\{\{\s*(ref|source)' models/ \
&& note "A3 star on a ref()/source()"
# A4 Ownership metadata present.
jq -e '[.nodes[] | select(.resource_type=="model")
| select((.group == null) and (.config.meta.owner == null))] | length == 0' \
target/manifest.json >/dev/null || note "A4 models with no group and no meta.owner"
# A5 Incremental hygiene.
jq -e '[.nodes[] | select(.config.materialized=="incremental")
| select((.config.unique_key == null)
or (.config.on_schema_change == "ignore"))] | length == 0' \
target/manifest.json >/dev/null || note "A5 incremental without unique_key, or on ignore"
# A6 Test composition, printed as a number rather than asserted.
jq -r '.nodes[] | select(.resource_type=="test") | .test_metadata.name' \
target/manifest.json | sort | uniq -c | sort -rn
exit "$fail"
A1–A6 run on a parsed manifest, which is why they can gate a pull request without a warehouse. Be honest about what is missing: the macro expansion-ratio check needs compiled_code, and Chapter 6 established that dbt compile connects to the warehouse and runs queries — so it is not free and does not belong in this script. A6 deliberately prints rather than fails: it is a number a human reads, not a threshold, because nobody has a defensible number for what test composition should be.
Core mental model
Split every review into two piles before you read a line. Pile one is what a machine can check — snapshot ancestry, source fan-out, stars on refs, ownership metadata, incremental config, test composition — and it belongs in CI, never on your list. Pile two is what only a person can answer: intended grain, whether a number is published anywhere a human looks, whether the backfill is affordable tonight, whether the blast radius contains something unrebuildable, whether the owner is reachable, whether the name still tells the truth. Everything you read for lives in pile two.
Why it matters
The design principle is the staff-level content here, and it is a claim rather than a platitude: anything a machine can check must not appear on the human checklist at all. An item a machine could verify, that a human is asked to verify, gets ticked without being read — and its presence teaches the reviewer that the whole list is theatre. That is why this checklist is six mechanical items the reviewer never sees, plus nine questions no linter can ask. The asymmetry in the widget is the other half: precision is not a bonus on top of recall. A reviewer who flags the sanctioned select * from final alongside the real star on a ref() has taught their author that the rule is arbitrary, and the next real finding gets discounted.
The waiver column
Every human item can be waived as "N/A because ___", recorded in the pull request. Without it, reviewers who genuinely do not need an item start ticking it falsely, and one falsely ticked item devalues the other eight.
The ordering rule
Items 1–4 block a merge. Items 5–9 may merge with a written follow-up. Stating this up front is what stops a checklist from becoming a veto, and it is why cosmetics — a trailing order by, a noqa comment — never appear on it at all.
The anti-gaming line
All nine ticked in under a minute is a process finding, not a code finding. It belongs in the next retrospective, not in a comment to the author. A checklist without this line degrades into a form within about six weeks.
Precision and recall, applied to review
Recall is the share of real findings you blocked on. Precision is the share of what you blocked on that mattered. Optimising recall alone produces a reviewer nobody reads; optimising precision alone produces a reviewer nobody needs. This is the only widget in this knowledge base that charges you for a false positive, and that is deliberate.
The two lists, and why nothing appears on both
Machine, in CI (6)
Human, in review (9)
A1 No snapshot has a model ancestor
B1 Does this PR change what one row means? Then the grain test changes in the same PR
A2 Every source has exactly one direct stg_ child
B2 Name the invariant — what is supposed to be identical before and after
A3 No select * directly on a ref() or source()
B3 Did you SEE the new test fail? An untested predicate is not a test
A4 Every model has a group or meta.owner; every exposure owner has an email
B4 For any join added, which side is the "one" side, and is its key unique-tested and green today
A5 No incremental on on_schema_change: ignore; every one declares a unique_key
B5 Who reads this — name the exposures and the humans, and tell them before merge
A6 Test composition printed as a number
B6 Is this reversible, what happens to rows already in the table, and is any snapshot in the blast radius
—
B7 Does this change the cost shape? Slow and expensive are different axes
—
B8 Is the owner reachable at 2am, by name AND by email — and do they know this changed
—
B9 Does the model’s prefix and path still tell the truth about its grain
Nothing appears on both lists, and that is the rule rather than an accident of drafting. dbt Labs publish their own analytics pull-request template — six items, by Jess Williams, 2021-11-29 — and it is nearly five years old and process-shaped rather than correctness-shaped: it asks whether you have documented and tested, not what one row means. That gap is exactly what this list fills; run both if you like, they do not overlap.
Common mistake
Putting "check the row count before and after" on the human checklist. It contradicts three chapters at once. This knowledge base has three published defects with a row-count delta of exactly zero — ep_1008 at 1,980 ms, pct_completed at 93,434.3, and Chapter 12’s fan-out at 1,000 in and 1,000 out with 11.63% wrong. Worse, it teaches the reviewer that a reconcile is evidence, so the next wrong number gets approved faster than it would have without the checklist.
Blocking a merge on a trailing order by, a noqa comment or a naming preference. You spend the authority you will need for the snapshot on line 3 of the same PR. Every false flag costs the author time and costs you credibility, and the reviewer who flags everything ends up with exactly the same influence as the one who flags nothing — which is the asymmetry this section exists to charge you for.
Better habit
Read the YAML before the SQL. The grain claim, the tests and the ownership block tell you what the author BELIEVES the model is, and most real findings are a disagreement between that belief and the SQL underneath it.
Leave the fix, not just the finding. A comment carrying the corrected config block gets applied; a comment saying "this should have a unique_key" gets a reply asking which one.
Say the counter-case out loud when you flag something: "this is right when the gate is met as a conjunction, and here it is not". Findings delivered with their exception get fixed; findings delivered as rules get argued with.
What is in no linter, and never will be
Intended grain. Whether a number is published anywhere a human actually looks. Whether the backfill is affordable tonight. Whether the blast radius contains something unrebuildable. Whether the owner is reachable at 2am. Whether the model’s name still tells the truth about what one row means. Whether a "domain" folder has a real second team behind it. Whether a macro’s compiled output is predictable before you look at it. That list is the entire justification for a human review existing at all — and every item a machine could have checked that you put next to it dilutes the eight that matter.
The best check in this chapter is a question you ask three people
Take the three marts with the most downstream exposures and ask three different people who owns each. If you get three answers, the ownership metadata is decorative regardless of what the manifest says. No tool can run this check, it takes about four minutes, and it has a higher hit rate than anything in the CI script above.
"How would you design a code-review checklist for a data team?"
The answer that lands is a design principle rather than a list: anything a machine can check must not appear on the human checklist at all, because an item a human is asked to verify that a machine could have verified gets ticked without being read — and its presence teaches the reviewer that the whole list is theatre. Then name the three things that turn a list into an artifact people keep: a waiver column, so "N/A because ___" is recorded rather than falsely ticked; a stated ordering rule, so people know which items block; and an anti-gaming line, because all nine ticked in under a minute is a process finding, not a code finding.
Remember this
The build was green, the tests passed and the review approved it — three gates, none of which can see intent, grain or irreversibility. Six checks belong to CI and nine questions belong to you, and nothing appears on both lists.
07 · Eight good moves, one fatal order
Ninety Days, In the Wrong Order
tooling-first tunes CI for a repository with nothing in it, migrate-everything-first has no feedback loop and no frozen baseline, governance-first spends political capital on rigidity before there is a consumer to be rigid for, and tests-later reaches N models with nobody's idea of a good week to add two hundred tests.
⏱ 4 min · Topic 7 of 9
Every move in the planner below is a good idea and every one of them is on somebody's real ninety-day plan. Eight moves total eighty-two days against a ninety-day quarter, so the budget is not what fails these plans — the order is.
Author the order, then run the quarter. Preconditions stay hidden until you hit them, because in a real migration they do.
Core mental model
Ship one vertical slice end to end — source, staging, and one mart a real dashboard already reads — with tests, CI and a scheduled production run, then widen. Every other ordering builds machinery for a project shape that does not exist yet, and every one of them fails for a different reason: tooling-first tunes CI for a repository with nothing in it, migrate-everything-first has no feedback loop and no frozen baseline, governance-first spends political capital on rigidity before there is a consumer to be rigid for, and tests-later reaches N models with nobody's idea of a good week to add two hundred tests.
Why it matters
The metric substitution carries the whole section: measure LEGACY SCRIPTS RETIRED, not models built. "Models built" rewards duplication and is fully satisfied by a project that has doubled the number of places a number is computed — Chapter 12 put it plainly, the naive split is eight problems with better names. Retirement is the only metric on the list that can be satisfied solely by finishing. And the correct ordering is not correct because it feels incremental; it is correct because it is the only one that produces a falsifiable acceptance gate on day one: is there a mart in production dbt that a human actually uses?
The falsifiable gate
A day-one acceptance test that can come back NO. "Is there one mart in production dbt that a human uses?" is binary. "How many models have we built?" is not falsifiable — it can only go up, which is why it is the metric every stalled migration reports.
Migrate by consumer, not by table
A consumer can say "this is right"; a table cannot. Retire a legacy script only after an AGREED number of green comparison days, decided before you start — because Chapter 12 named the alternative: "we will retire it when we are confident" means never.
The frozen baseline
The reason migrate-everything-first is both the most common and the most expensive ordering: while you migrate for six months the legacy system keeps changing, so you never have a fixed thing to compare against. audit_helper against a frozen legacy relation is only meaningful if somebody actually froze it.
What does NOT belong in ninety days
Mesh, contracts everywhere, the semantic layer, and an engine migration. Each is a good idea with a precondition you have not met. Naming the exclusions is as much of the plan as naming the moves — an unbounded plan is how the first three months produce zero delivered numbers.
Five orderings, and the specific way each one fails
Ordering
What it looks like
How it actually fails
Tooling first
CI, linting, pre-commit hooks and the parser job before any model ships
Six weeks of platform work, zero delivered numbers, and a sponsor who has disengaged. And the CI itself is tuned for a project shape that does not exist yet, so most of it gets rewritten once real models arrive.
Migrate everything first
Port all 300 models, then wire up tests, CI and scheduling
The most common and the most expensive. No feedback loop, so you discover the layering is wrong at model 300. And the legacy system keeps changing underneath you with no frozen baseline, so there is never a moment where two things can be compared.
Governance first
Contracts, groups, access levels and exposures before the first consumer
Chapter 13 named it: a contract on an internal model is a promise you make to yourself. You spend political capital on rigidity before there is a consumer to be rigid for, and the rigidity is then blamed for the slowness.
Tests later
Build models now, add the test suite once the shape settles
You reach N models with Chapter 7’s honest starting state — a uniqueness badge on most models and nothing else — and there is never a good week to add two hundred tests. There never was going to be one.
One vertical slice end to end
Source → staging → one mart a real dashboard already reads, with tests, CI and a scheduled production run. Then widen.
It does not fail, and the mechanical reason matters: it is the only ordering that produces a falsifiable acceptance gate on day one. Everything else can only report progress.
"Should we adopt the new engine now?" — three teams, three answers
Situation
The answer
The reasoning that makes it defensible
A 30-model team, Snowflake, no platform engineer
Yes in development this month. Not in production until your adapter is GA.
The developer-experience win is disproportionate for a small team, and a 30-model long tail is small enough to read in an afternoon. Pre-flight two things first: custom materializations (the engine silently skips analysis on those nodes) and Python models.
A 900-model team, mid-quarter
No as a project-wide switch this quarter. Yes as a non-blocking dbt parse --use-v2-parser CI job TODAY.
The job costs nothing, blocks nothing and produces a rolling compatibility report you can burn down. At 900 models the migration cost is not the engine — it is the long tail. This is a code-cleanup project wearing an engine-upgrade costume; budget it as one. And name the failure mode: reaching for static_analysis: off broadly to make the migration "pass" means paying the whole migration and keeping none of the benefit.
A team on an unsupported adapter — Postgres, most commonly
No, and the decision is not yours to make.
v2 adapters are re-implemented in Rust inside the dbt-core monorepo against a pre-existing ADBC driver, not recompiled from your Python adapter — so the wait is real. Stay on 1.x, pin it, track two dates (your adapter’s status and your minor’s support window), and do the readiness hygiene now so that when it lands you are a config change away rather than a quarter away.
Where dbt is the wrong tool entirely — mechanisms, not vibes
Workload
Why dbt is wrong for it
Sub-minute or streaming latency
dbt is batch and it is INVOKED. There is no continuous operator, so there is no shape of dbt project that gives you a five-second freshness guarantee.
Row-level operational writes, or serving an application
No single-row upsert and no cross-model transaction. dbt builds relations; it does not maintain them under concurrent writes.
Ingestion and CDC capture
Chapter 1 said it first: dbt has no extractor. The T has no E in front of it, and pretending otherwise produces a source() pointed at something nobody loads.
Heavy ML training and iterative algorithms
Chapter 21 drew this line in detail: the output is not a relation, and a pickle in a BLOB column defeats every guarantee at once.
No warehouse, or no CREATE privilege
dbt’s entire model of the world is "create a relation". Without the privilege there is nothing for it to do.
A very small, very stable transformation footprint
Three views that change twice a year. The repository, the CI, the profiles, the package upgrades and the onboarding exceed the benefit — and this is the honest counter-case nobody says out loud. Omitting it loses exactly the reader this chapter is for.
Query-time joins across huge dimension sets
Chapter 20 argued this one: the semantic layer decides what a number means, and some join shapes belong to the query engine rather than to a materialized model.
Referential integrity as a runtime guarantee
Chapter 13 established that most warehouses RECORD rather than enforce constraints. A relationships test is a check after the fact, not a guarantee during the write.
Orchestrating non-SQL work
Chapter 15 owns this: dbt is invoked BY an orchestrator, and a dbt project pretending to be one ends up with a run_query() macro calling an API.
Common mistake
Reporting "models built" as the migration’s headline metric. It rewards duplication and is fully satisfied by a project that has doubled the number of places a number is computed. Test count is the same failure one level down — it rewards not_null spam. Documentation coverage percent is the worst of the three, because Chapter 11 established that documenting a column that does not exist is completely silent: no warning is emitted at all, so --warn-error cannot help you.
Starting the migration with the engine upgrade because it feels like the foundation. You migrate before you have a green CI that can prove nothing moved, so when a number differs you cannot tell whether the cause is the engine or your code. That single ambiguity has cost teams weeks — and it is entirely avoidable by running the parser job non-blocking for months first.
Better habit
Name the acceptance gate before the plan: what fact, checkable in week two, would tell us this is working? If the honest answer is a count that can only go up, you have a report rather than a plan.
Write the exclusions into the plan explicitly — mesh, contracts everywhere, the semantic layer, the engine — so that the first person to propose one has to argue against a decision rather than into a vacuum.
Decide the number of green comparison days BEFORE the comparison starts, and write it down. Deciding afterwards is how "we will retire it when we are confident" gets said out loud.
The measurement plan, in four lines
Rounded to quarters of the quarter, not the widget’s own day-by-day count — the planner below lands the eight moves at 1–15, 16–32, 33–57 and 70–82 depending on the order chosen; read the bands here as approximate phases, not exact boundaries. Days ~1–15: one vertical slice, dbt build green in development, audit_helper clean against a FROZEN legacy relation. Measure: is there one mart in production dbt that a human uses — binary, not a count. Days ~15–32: CI on every pull request, a scheduled production run, source freshness on every source. Measure: wall clock from opening a PR to a green build, and the date of the first freshness WARN plus whether anybody acted on it. Days ~33–60: migrate by consumer, not by table, and retire a legacy script only after N agreed green comparison days. Days ~60–90: a grain test on every mart, a group and owner on every model, an exposure for every consumer. The plan and every one of these metrics is this knowledge base’s, not dbt Labs’ — say so when you present it.
Keep the freshness qualifier when you write the day-15 goal
On a plain run, a source freshness WARN does not fail the build — which is why the useful day-15 metric is the date of the first WARN and whether a human acted on it, not an exit code. With --warn-error a freshness WARN does exit 1, but dbt-core #12812 is open, so you gain the gate and lose the report. Chapter 5 has the full argument for when to gate a freshness WARN at all.
"You are hired to lead a migration to dbt. What do you do in the first ninety days?"
Do not list workstreams. Give the ordering and the reason it is the ordering: one vertical slice end to end — source, staging, one mart a real dashboard already reads — with tests, CI and a scheduled production run, because it is the only sequence that produces a falsifiable acceptance gate on day one. Then name what you would measure and why: legacy scripts retired, not models built, because models built rewards duplication and retirement is the only metric that can only be satisfied by finishing. Then name what you would explicitly exclude in the first quarter — mesh, contracts everywhere, the semantic layer, an engine migration — and say why each has a precondition you have not met. Naming the exclusions is what separates somebody who has led one from somebody who has read about one.
Remember this
One vertical slice end to end, then widen. Measure legacy scripts retired, not models built — it is the only metric on the list that cannot be satisfied by adding things. And write the exclusions down, because an unbounded quarter delivers nothing anyone can check.
08 · The artifact, the open items, the judgement
What You Now Owe The Next Engineer
the review widget for the invariant, the column widget for the blast radius, and the recall widget for the chapter that already told you.
⏱ 4 min · Topic 8 of 9
Twenty-two chapters, one podcast platform, and a project that has been wrong in public more than once on purpose. What is left is the artifact: fifteen checks, six of which you should never see, and nine questions no tool will ever ask for you.
Copy it, change it, and put your team's name on it. A checklist you inherited is a form; a checklist you edited is a practice.
Core mental model
A staff data engineer is not someone who knows more dbt. They are someone who can name the invariant, name the blast radius and name the owner — before the change ships, in one sentence each, without opening the manifest. Every widget in this chapter was a rehearsal for one of those three sentences: the review widget for the invariant, the column widget for the blast radius, and the recall widget for the chapter that already told you.
Why it matters
Everything in this knowledge base was building toward one capability, and it is not writing models. It is being the person in the room who can say what one row means, what would have to be true for this number to be wrong, who finds out if it is, and whether anything in the blast radius cannot be rebuilt. The tooling underneath that capability will keep moving — four of the eight claims in section 2 expired inside a year — and none of the movement changes the questions. Fusion, v2, SQLMesh and whatever follows are all answers to "how fast can the engine tell me". Nothing on the horizon answers "what should this number be", and that is the part somebody has to own.
The three sentences
"This changes what one row means, so the grain test changes with it." "This reaches these two exposures and this snapshot, and the snapshot cannot be rebuilt." "This is owned by this person, at this email, and they know." A reviewer who can say all three has replaced a checklist with a habit, which is the point of the checklist.
The open items, recorded rather than fixed
This project carries known defects with owners, deliberately: unique_fct_subscription_revenue_subscription_id is on the wrong grain and should key on payment_id; ep_1005’s fan-out is unfired rather than absent; stg_subs has zero consumers and is still correct; agg_daily_listens has naming drift and will be renamed when next touched, with no mass rename; and wavelength_app.shows carries freshness: null on purpose. That last one produces the honest checklist item — every source has a DELIBERATE freshness decision, including null.
Whether a test is a "node"
The dbt ecosystem itself is not consistent here. A test is a node in the manifest — resource_type: test, with its own unique_id — but it is not a model, and casual usage ("the graph has N nodes") sometimes means every manifest resource and sometimes means only the buildable ones. Read manifest.json directly when it matters: model, test, seed, snapshot, source and exposure are each a distinct resource_type, and only some of them are what dbt build actually runs.
The printable review checklist — copy this into your repositoryworked example
Config
WAVELENGTH DBT REVIEW CHECKLIST — v1, 2026-08-04
DESIGN RULE
Anything a machine can check must NOT appear on the human list. An item a
human is asked to verify that a machine could have verified gets ticked
without being read — and its presence teaches the reviewer that the whole
list is theatre.
PART A — CI PREFLIGHT (6). The reviewer never ticks these. CI does.
A1 No snapshot has a model ancestor.
dbt ls --select +<snapshot> --resource-type model --output name
ANY output at all is the finding. Zero false positives.
A2 Every source has exactly one direct stg_ child.
dbt ls --select source:<source>+1 --resource-type model --output name
A3 No select * directly on a ref() or a source().
rg -n --glob '*.sql' 'select\s+\*\s+from\s+\{\{\s*(ref|source)' models/
Does NOT match "select * from renamed" — that pattern is sanctioned.
A4 Every model has a group or meta.owner.
Every exposure owner has an EMAIL, not just a name.
A5 No incremental model left on on_schema_change: ignore.
Every incremental model declares a unique_key.
A6 Test composition, printed as a number rather than asserted:
unique + not_null as a share of all tests
tests per staging model vs tests per mart
COST, HONESTLY. A1-A6 read a PARSED manifest: free, offline, no warehouse.
The macro expansion-ratio check needs compiled_code, and dbt compile
connects to the warehouse and runs queries. Do not sell the suite as free.
PART B — THE HUMAN LIST (9).
ORDERING RULE: B1-B4 block a merge. B5-B9 may merge with a written follow-up.
WAIVER: any item may be waived as "N/A because ___", recorded in the PR.
Correctness of this change
B1 Does this PR change what one row of this model means?
If yes, the grain test changes in the SAME PR — or you write down why not.
B2 Name the invariant. What is supposed to be identical before and after?
(count(*) is the right invariant for a minority of refactor steps.)
B3 Did you SEE the new test fail? A test that has never been red is a test
whose predicate is unverified.
B4 For any join added: which side is the "one" side, and is its key
unique-tested and green TODAY?
Consequence beyond this file
B5 Who reads this? Run the impact traversal, name the exposures and the
humans, and tell them BEFORE merge.
B6 Is this reversible? What happens to rows already in the table, and is
any snapshot in the blast radius? (--full-refresh is a silent no-op there.)
B7 Does this change the cost shape? Slow and expensive are different axes.
Handover
B8 Is the owner reachable at 2am — by NAME and by EMAIL — and do they know
this changed?
B9 Does the model's prefix and path still tell the truth about its grain?
A stg_ that reads a snapshot is not a stg_. A "domain" folder with one
author and no CODEOWNERS entry is a preference, not a domain.
ANTI-GAMING
All nine ticked in under a minute is a PROCESS finding, not a code finding.
It goes to the next retro, not to the author.
NOT ON THIS LIST, ON PURPOSE
"Check the row count." Three defects in this project have a row-count delta
of exactly zero. A reconcile is not evidence, and putting it here would teach
the opposite.
WAIVER LOG
Item | Waived by | Reason ("N/A because ___")
-----|-----------|---------------------------
Fifteen items: six a reviewer never sees and nine no tool can ask. The three things that make it an artifact rather than a list are all in here — the waiver column, the ordering rule, and the anti-gaming line. Delete anything that does not fit your project, but delete it deliberately: the failure mode is not a short checklist, it is a long one nobody reads.
What each part of this knowledge base actually gave you
Chapters
The capability
The sentence it lets you say
1–4 · Foundations
What dbt is, and what a model, a DAG and a materialization are
"This is a SELECT statement dbt turns into a relation, in an order it worked out from the refs."
5–11 · Building a project
Sources, staging, macros, tests, unit tests, incremental models, snapshots, documentation
"This number is bounded, reconciled against a second column, and its history is captured from the source."
12–18 · Structure and production
Layering, contracts and groups, CI, orchestration, cost, debugging, packages
"This change is deferred against production, costs this much, is owned by this group, and here is where it broke."
19–22 · Scale and expertise
Cross-project boundaries, the semantic layer, Python models, and judgement
"This does not belong in dbt at all — and here is the mechanism, not the vibe."
Chapter 19 owns the cross-project boundary — multi-project references, the public / protected / private surface between teams, and what a contracted model buys the consumer. This chapter cites that boundary as the counter-case for the staging entry and does not re-argue it.
Common mistake
Adopting this checklist unchanged because it is in a knowledge base. You inherit judgements that were made for a Snowflake project on one podcast dataset — the graph as Chapter 21 left it: 27 graph nodes, 24 non-exposure, 19 buildable, 28 manifest nodes. Nineteen buildable nodes is not a large project by anyone’s definition, and the fixes above scale with size in ways this chapter never had reason to demonstrate. Four of the nine catalogue entries are this knowledge base’s engineering judgement rather than dbt Labs’ guidance, and every one of them has a counter-case that might be your project. A checklist you edited is a practice; a checklist you inherited is a form, and forms get ticked.
Treating the finished knowledge base as the end of the version-tracking work. Section 2 is not a section about SQLMesh — it is a section about half-lives. Four of eight widely repeated claims expired inside a year, and every one of them was learned from a source that is probably still feeding whoever learned it. Put a recurring reminder against two dates: your adapter’s Fusion status, and your minor version’s support window.
Better habit
Re-verify before you quote it, with a date attached. Six words of provenance is the entire defence against the failure mode this chapter opened with.
When you inherit a project, run detection checks 1, 2 and 4 before you read a single model. Ten seconds of dbt ls tells you more about the project’s health than an hour of reading SQL.
Leave every project with the three sentences written down for its three most-read marts: the grain, the blast radius, and the owner with an email. That is what you owe the next engineer, and it is the only part of the handover nobody else can write.
The open items are recorded, not hidden
A knowledge base that ships a project with no known defects is teaching a fiction. This one carries five, each with an owner and a decision: a uniqueness test on the wrong grain (should key on payment_id), an unfired rather than absent fan-out on ep_1005, a staging model with zero consumers that is nonetheless correct, naming drift on agg_daily_listens to be fixed when it is next touched rather than by a mass rename, and a source with freshness: null set deliberately. The last one is the most useful, because it produces the honest version of a checklist item most teams get wrong: every source needs a DELIBERATE freshness decision, and null is a legitimate one when it is written down.
Where to go next, and it is not another dbt chapter
Three of this chapter’s "wrong tool entirely" rows point somewhere specific. Orchestration — retries, sensors, SLAs, backfills — belongs to the Airflow knowledge base, and dbt Chapter 15 only covered how dbt is invoked from one. Whether the feature or the dimension is modelled correctly in the first place belongs to Data Modeling. And the ELT-versus-ETL decision, CDC capture and schema-evolution mechanics belong to Data Pipeline. dbt is one letter of three, and the judgement in this chapter is mostly about knowing which letter you are holding.
The closing answer, for the question that ends most senior interviews
"What would you change about how your last team used dbt?" The weak answer is a tooling complaint. The strong answer picks one anti-pattern, names the detection signal that would have caught it, names what it cost by year two, and — this is the part almost nobody does — names the case where it would actually have been the right call. Then close on the review point: we could check six things mechanically and were checking them by hand, so nobody read them; and we were not asking the nine questions no tool can ask. Moving items from the human list to CI is not process work. It is the only way the human list survives contact with a busy week.
Remember this
Six checks a machine owns, nine questions you own, and three sentences you should be able to say about any change before it ships: what one row means now, what the blast radius contains, and who is reachable at 2am. The engines will keep changing. Those three sentences will not.
09 · You’ve finished the knowledge base
You’ve finished the knowledge base
That is the whole journey: what dbt is and what it refuses to be, models and the DAG, sources, staging, macros, tests, unit tests, incremental models and snapshots, documentation and exposures, project structure, contracts, CI, orchestration, cost, debugging, packages, cross-project boundaries, the semantic layer, Python models — and, here at the end, the judgement to say which of it does not apply to you.
⏱ 3 min · Topic 9 of 9
Next chapter
dbt, From First Model To Staff-Level Judgement
That is the whole journey: what dbt is and what it refuses to be, models and the DAG, sources, staging, macros, tests, unit tests, incremental models and snapshots, documentation and exposures, project structure, contracts, CI, orchestration, cost, debugging, packages, cross-project boundaries, the semantic layer, Python models — and, here at the end, the judgement to say which of it does not apply to you.
You can now write a model, defend its grain in review, price it, operate it on call, and say plainly when the work belongs somewhere else entirely. Take the checklist, change it until it fits your team, and go and retire a legacy script.