13. Tuning and operations
dbt · 13. Tuning and operationsdbt itself is almost never the bottleneck. This chapter is about the two things that are — your warehouse bill and your project’s structure — and the operational habits that keep both under control.
Find out where the time goes
Section titled “Find out where the time goes”Before optimising anything, measure. run_results.json has per-model timings; loading it into a table after every run is a twenty-line job and the best investment a growing dbt project can make.
select node_id, execution_time, rows_affected, run_started_atfrom dbt_run_resultswhere run_started_at > dateadd(day, -30, current_date)order by execution_time desclimit 20The distribution is always lopsided. In nearly every project, five models are half the runtime. Optimising anything else is wasted effort.
Ask three questions of the top of that list:
- Is it incremental and should it be?
- Is the SQL doing something avoidable — a cross join, a window over an unpartitioned table,
select *through six layers? - Does it need to run this often?
Threads
Section titled “Threads”threads: 16The number of models dbt will build concurrently. Each thread is a concurrent warehouse query, so this is a warehouse setting wearing a dbt hat.
4 → 8 usually a large win 8 → 16 usually a win 16 → 32 depends entirely on the warehouse 32 → 64 almost always pointlessRaising threads stops helping when:
- The graph is too narrow. A long dependency chain has nothing to parallelise. If four models are ready at a time, thirty-two threads is thirty-two idle slots.
- The warehouse queues. Snowflake queues beyond the warehouse’s concurrency; you have moved the queue, not removed it.
- Contention appears. Many concurrent writes to the same schema, or merges against the same large table.
Raise it, measure, keep the increase if wall-clock time improved. There is no formula, and the honest number depends on your graph shape and warehouse size.
More threads is not more expensive on its own — the same queries run either way, more of them at once. On per-second-billed warehouses, finishing sooner can cost less.
The cost model
Section titled “The cost model”For most teams, the dbt bill is dominated by a small number of decisions.
1 FULL REBUILDS OF LARGE TABLES → incremental. chapter 5. the biggest single lever there is.
2 DEVELOPMENT RUNS OVER FULL DATA → target.name conditionals. chapter 11. engineers rebuilding 2bn rows to test a CASE statement is pure waste.
3 VIEWS STACKED ON VIEWS → materialise something in the middle. a five-layer view chain executes the whole chain on every dashboard load.
4 RUNNING HOURLY WHAT CHANGES DAILY → tag by cadence. run daily models daily.
5 CI BUILDING EVERYTHING → slim CI. chapter 12.Cadence deserves a mention because it is the cheapest fix. Not everything needs to run every hour:
models: jaffle_shop: marts: finance: +tags: ['daily'] realtime: +tags: ['hourly']dbt build --select tag:hourly # every hourdbt build --select tag:daily # once, overnightTen minutes of configuration, and a large fraction of the compute disappears.
Model-level optimisation
Section titled “Model-level optimisation”When a specific model is slow, the levers are ordinary SQL and warehouse ones:
Partition and cluster on the columns you filter and merge on. For an incremental model merging on order_id over a table clustered by order_date, the merge scans everything. Cluster on what the merge touches.
{{ config( materialized = 'incremental', unique_key = 'order_id', cluster_by = ['order_date', 'order_id']) }}Stop select * propagating. Column-store warehouses charge for columns read. A staging model that selects every column of a 200-column source, feeding three marts that need six columns each, reads 200 columns three times. Select what you need in staging.
Filter as early as possible. Deleted rows filtered out in the mart have been carried through every intermediate step.
Watch for accidental fan-out. A join on a non-unique key multiplies rows silently. A unique test on the model’s grain catches it; without one, the model just gets slower and the numbers get wrong.
Read the query plan. dbt logs the warehouse’s query ID; the plan is in the warehouse’s history. That is where the actual answer is.
Project structure at scale
Section titled “Project structure at scale”Chapter 2 introduced staging / intermediate / marts. At a thousand models the rules that matter:
One source, one staging model, always. No exceptions. When a source table changes, exactly one file needs editing.
Staging models do not join. The moment they do, the 1:1 mapping is gone and nobody can tell what a staging model represents.
Marts are named for the business. subscriptions, not stg_stripe__subscriptions_joined_with_users_v2.
Group marts by domain, not by source.
models/marts/ ├── finance/ ├── marketing/ ├── product/ └── core/ shared dimensionsDomain folders let you assign ownership, apply tags by area, and run one domain independently. Source-shaped folders give you none of that.
Fan-out over fan-in. A model refd by ten others is fine. A model refing ten others is usually doing too much.
Delete unused models. The docs graph shows models with no descendants and no exposure. They cost money every run and confuse everyone reading the project. Regularly deleting things is a maintenance practice, not a nice-to-have.
dbt-project-evaluator
Section titled “dbt-project-evaluator”A dbt Labs package that audits your project against these conventions and produces a report.
packages: - package: dbt-labs/dbt_project_evaluator version: [">=0.14.0"]It flags:
✗ models with no tests ✗ models with no description ✗ hard-coded relation references ← the big one ✗ direct joins to sources (skipping staging) ✗ staging models that ref other staging models ✗ root models (no ref, no source) ✗ multiple sources joined in one model ✗ chained view dependencies ✗ unused sources ✗ exposures with no downstream testsThe hard-coded reference check alone justifies installing it. Chapters 3 and 12 both explain why that bug is expensive; this is how you find the ones already in your project.
Run it in CI. Start with a warning-only mode and a documented list of accepted exceptions, then tighten — turning it on as a hard failure over an existing project produces a report nobody will action.
Orchestration
Section titled “Orchestration”dbt does not schedule itself. Four common answers:
dbt CLOUD JOBS simplest if you already pay for it cron, notifications, logs, CI hooks
AIRFLOW right when dbt is one step among many BashOperator/dbt operators, or Cosmos
DAGSTER asset-oriented; dbt models become Dagster assets with real lineage
CRON + A CONTAINER genuinely fine for small projects do not over-engineer thisOne task or many?
Section titled “One task or many?”The recurring Airflow question, and worth being concrete about.
ONE TASK BashOperator: dbt build ✓ simple, fast, dbt handles the DAG ✗ one red square in the UI ✗ retries redo everything (unless dbt retry)
ONE TASK PER MODEL (Cosmos) renders the dbt DAG as Airflow tasks ✓ per-model visibility and retries ✓ Airflow-native alerting per model ✗ 2000 models = 2000 task instances ✗ scheduler load, slow UI, dbt invoked per model unless carefully configuredStart with one task. Move to per-model rendering only when you actually need per-model retries or visibility, and know what you are paying in scheduler load. A good middle path is a handful of tasks split by tag or domain — dbt build --select tag:finance as one task — which gives useful granularity without thousands of task instances.
Airflow’s chapter 15 covers the pairing from the other side.
Whatever runs it, use dbt build and not run then test (chapter 6), and make dbt retry the retry action (chapter 12).
Multi-project, and dbt Mesh
Section titled “Multi-project, and dbt Mesh”A monolithic project eventually strains: everyone edits one repository, one run builds everything, ownership is unclear.
dbt Mesh splits it. A downstream project refs an upstream project’s public models:
# dependencies.yml in the downstream projectprojects: - name: platform_coreselect * from {{ ref('platform_core', 'dim_customers') }}Upstream declares what is shareable:
models: - name: dim_customers config: access: public group: platform latest_version: 2Each project has its own repository, schedule, CI and owners, and cross-project contracts are enforced.
Do not reach for this early. The coordination cost is real — versioned interfaces, cross-project scheduling, artifact sharing — and it only pays when you genuinely have separate teams with separate release cadences. Under a few hundred models with one team, a monolith with good folder structure and access configs is better. Note also that some of the cross-project tooling is a dbt Cloud feature; check what your setup supports.
Style and review
Section titled “Style and review”Adopt a style guide and enforce it mechanically. SQLFluff with a dbt templater lints in CI:
sqlfluff lint models/ --dialect snowflake --templater dbtConsistent casing, indentation, CTE structure and no select * in marts. Machine-enforced style removes an entire category of pull request comment and lets review focus on logic.
The review checklist that catches the most, in this guide’s experience:
□ ref() everywhere, no hard-coded names □ unique + not_null on the grain □ description on any new mart □ incremental? unique_key correct and tested? □ incremental? lookback window sized? □ on_schema_change set explicitly □ no business logic added to a staging model □ no new model that duplicates an existing oneUpgrades
Section titled “Upgrades”dbt Core follows semantic versioning and minor releases land regularly.
require-dbt-version: [">=1.9.0", "<2.0.0"]Pin a range in dbt_project.yml so a developer on a different version finds out immediately rather than producing subtly different SQL.
Upgrade in the same order every time: bump in a branch → dbt parse → run the full test suite in CI → run in production → re-publish the manifest (chapter 12’s version-mismatch trap). Upgrade packages separately from dbt itself, so a failure has one cause.
The bigger question in 2026 is the Fusion engine. It is a substantially different implementation, and migration considerations, feature parity and licensing were all still settling as this was written. Treat it as a project, read dbt’s current migration guidance, and do not assume a drop-in replacement.
Operational habits
Section titled “Operational habits” DAILY watch source freshness first — a broken loader is the most common incident, and it is not dbt's fault
WEEKLY look at the slowest models look at models with no descendants
MONTHLY review test coverage on new marts drop abandoned CI schemas check warehouse spend by model
ALWAYS every incident becomes a test every hard-coded ref gets fixed every new mart gets a descriptionWhat to take from this chapter
Section titled “What to take from this chapter”- Measure before optimising. Load
run_results.jsoninto a table; five models will be half your runtime. threadsis a warehouse setting in disguise. Raise it, measure, stop when the graph is too narrow or the warehouse queues.- Cost is dominated by five things: full rebuilds, dev runs over full data, stacked views, running hourly what changes daily, and CI building everything. Tagging by cadence is the cheapest fix available.
- Cluster on what you merge and filter on, stop
select *propagating, filter early, and watch for silent fan-out. - At scale: one staging model per source, staging never joins, marts named for the business and grouped by domain, and delete what nothing uses.
- Install
dbt-project-evaluator— the hard-coded-reference check alone earns it. Introduce it as warnings first. - Start orchestration with one
dbt buildtask. Per-model rendering costs real scheduler load; split by tag before splitting by model. - dbt Mesh is for genuinely separate teams, not for tidiness. A monolith with good structure and
accessis better under a few hundred models. - Enforce style with SQLFluff in CI so review can be about logic.
- Pin
require-dbt-version, upgrade in a branch, and re-publish the manifest afterwards. Treat Fusion as a project, not an upgrade.
Next: failure modes — the specific ways dbt projects go wrong, and what each one looks like from the outside.