Skip to content

13. Tuning and operations

dbt 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.

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_at
from dbt_run_results
where run_started_at > dateadd(day, -30, current_date)
order by execution_time desc
limit 20

The 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:

  1. Is it incremental and should it be?
  2. Is the SQL doing something avoidable — a cross join, a window over an unpartitioned table, select * through six layers?
  3. Does it need to run this often?
threads: 16

The 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 pointless

Raising 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.

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']
Terminal window
dbt build --select tag:hourly # every hour
dbt build --select tag:daily # once, overnight

Ten minutes of configuration, and a large fraction of the compute disappears.

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.

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 dimensions

Domain 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.

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 tests

The 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.

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 this

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 configured

Start 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).

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 project
projects:
- name: platform_core
select * from {{ ref('platform_core', 'dim_customers') }}

Upstream declares what is shareable:

models:
- name: dim_customers
config:
access: public
group: platform
latest_version: 2

Each 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.

Adopt a style guide and enforce it mechanically. SQLFluff with a dbt templater lints in CI:

Terminal window
sqlfluff lint models/ --dialect snowflake --templater dbt

Consistent 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 one

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.

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 description
  • Measure before optimising. Load run_results.json into a table; five models will be half your runtime.
  • threads is 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 build task. 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 access is 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.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source