dbt run slow? Why it happens and 7 fixes that work
If your dbt run is slow, start with the critical path: run_results.json, model timing, and the DAG’s longest chain. Then apply the seven fixes that actually pay off.
Your dbt run is slow and the schedule is on fire. Don’t guess. In the next 10 minutes, you’ll identify the exact bottleneck with dbt’s artifacts and then apply the few fixes that consistently move the needle. Start by locating the critical path—the longest chain of models that gates the rest—using run_results.json timing. Then fix the usual culprits in order: accidental full-refresh, SELECT *, wrong materializations, missing clustering/partitioning, over-broad tests, and stale exposures pulling heavy ancestors. Warehouse tuning helps, but it’s usually the smaller half of the problem.
Find your critical path before changing anything
Everything that follows depends on what’s actually slow. Use dbt's artifacts to see which models consume time and how they line up in the directed acyclic graph (DAG).
1) Pull the top time sinks from run_results.json
After a representative run (the one that feels extremely slow), inspect timings:
cat target/run_results.json | jq '
.results[]
| select(.resource_type=="model")
| {unique_id: .unique_id,
name: .unique_id | split(".") | .[-1],
status: .status,
elapsed: .execution_time}
| select(.status=="success" or .status=="error")
' | jq -s 'sort_by(-.elapsed) | .[0:20]'
These 20 models likely drive most of your runtime. Keep this list; we’ll fix the ones that matter first.
2) Compute the longest chain in the DAG
The critical path is the path from a source to a leaf that maximizes total elapsed time. Use manifest.json for edges and run_results.json for weights. This small Python script does a simple topological sorting and longest-path calculation:
#!/usr/bin/env python3
import json, sys
from collections import defaultdict, deque
manifest = json.load(open('target/manifest.json'))
results = json.load(open('target/run_results.json'))
elapsed = {r['unique_id']: r.get('execution_time', 0) for r in results.get('results', [])}
nodes = {k:v for k,v in manifest['nodes'].items() if v['resource_type'] in ['model','seed','snapshot']}
edges = defaultdict(list)
indeg = defaultdict(int)
for uid, node in nodes.items():
for dep in node.get('depends_on', {}).get('nodes', []):
if dep in nodes:
edges[dep].append(uid)
indeg[uid] += 1
# topological order
q = deque([n for n in nodes if indeg[n]==0])
order = []
while q:
u = q.popleft(); order.append(u)
for v in edges[u]:
indeg[v]-=1
if indeg[v]==0: q.append(v)
# longest path by observed elapsed
best = {}; prev = {}
for u in order:
cur = elapsed.get(u, 0)
if u not in best:
best[u] = cur
for v in edges[u]:
if best.get(v, 0) < best[u] + elapsed.get(v, 0):
best[v] = best[u] + elapsed.get(v, 0)
prev[v] = u
end = max(best, key=lambda k: best[k])
path = []
while end in nodes:
path.append(end)
end = prev.get(end)
if end is None: break
print("Longest path (by observed seconds):")
for uid in reversed(path):
print(f"- {uid} ({elapsed.get(uid,0)}s)")
Now you know the exact sequence that gates your build. Everything else is parallelism noise.
3) Sanity-check warehouse vs. project issues
- Re-run just the critical path:
dbt build -m <first_node>+ --threads 8. If that’s still slow, it’s a dbt project shape issue, not orchestration. - Increase threads:
dbt build --threads 16, watch for warehouse saturation. Threads are reported intarget/run_results.jsonunderargs. - Compare a single heavy model on a bigger warehouse or Databricks cluster to isolate compute limits vs. SQL design.
The seven fixes that actually pay off
Apply in order of likelihood and payoff. Each fix includes the exact command, config, or query to use.
1) Stop accidental full-refresh on models that should be incremental
Symptom: long merges or full table scans on large fact tables. Cause: missing unique_key, using --full-refresh globally, or not filtering with is_incremental().
{{ config(
materialized='incremental',
unique_key='order_id',
incremental_strategy='merge',
on_schema_change='sync_all_columns'
) }}
with src as (
select * from {{ ref('stg_orders') }}
{% if is_incremental() %}
where updated_at > (select coalesce(max(updated_at), '1900-01-01') from {{ this }})
{% endif %}
)
select * from src
Confirm you’re not scanning the whole table:
- Snowflake: use Query History and check “scanned bytes” for this table.
- BigQuery: run
EXPLAINon the compiled SQL or check bytes processed. - Databricks: review the Spark UI stage I/O and filter pushdown.
Run one model to verify: dbt run -m fact_orders. If it went from minutes to seconds, you avoided a full scan. That’s real model performance improvement.
2) Replace SELECT * fan-out with explicit columns
Symptom: small upstream changes trigger massive downstream recompiles; wide tables slow serialization and network I/O. Detect with ripgrep:
rg -n "select \*" models/ macros/
Fix by naming columns and centralizing them:
-- macros/columns.sql
{% macro orders_columns() %}
order_id, customer_id, order_ts, status, total_amount
{% endmacro %}
-- models/fct/fact_orders.sql
select {{ orders_columns() }} from {{ ref('stg_orders') }}
Bonus: when you add a column, you edit one macro, not 12 models. It will also make dbt compile and execute faster.
3) Use the right materialization (views and ephemerals are not free)
Symptoms: repeating the same computation N times, or pushing heavy logic to a view model that the warehouse re-computes for every consumer.
| Pattern | Better choice | Config |
|---|---|---|
| Heavy view used by many dependents | table or incremental | {{ config(materialized='table') }} |
| Wide ephemeral reused 5+ times | table | {{ config(materialized='table') }} |
| Large dimensional table changing slowly | incremental | add unique_key and is_incremental() |
Quick audit: list heavy views and ephemerals that feed many children:
dbt ls --resource-type model --output json \
| jq -r 'select(.config.materialized=="view" or .config.materialized=="ephemeral") \
| "\(.name) children=\(.depends_on.nodes|length)"' \
| sort -k2 -nr | head -20
4) Add partitioning and clustering where it matters
Missing keys make incremental and filters slow. Example configs:
-- BigQuery
{{ config(
materialized='incremental',
partition_by={"field": "order_date", "data_type": "date"},
cluster_by=["customer_id"],
unique_key='order_id',
incremental_strategy='merge'
) }}
-- Snowflake
{{ config(
materialized='incremental',
unique_key='order_id'
) }}
-- Optional: post-hook to set clustering key
{% do run_query("alter table " ~ this ~ " cluster by (order_date)") %}
-- Databricks Delta
{{ config(materialized='table') }}
-- Optimize periodically from a job:
OPTIMIZE {{ this }} ZORDER BY (customer_id);
Validate with your warehouse’s metadata: confirm partitions or clustering keys are present and that the query avoids full scans.
5) Scope tests so they don’t scan entire fact tables
Generic tests are vital, but they can dominate time. Identify heavy tests:
cat target/run_results.json | jq '.results[] | select(.resource_type=="test") | {name:.unique_id, elapsed:.execution_time}' | jq -s 'sort_by(-.elapsed) | .[0:20]'
Scope or sample big tests:
-- schema.yml
models:
- name: fact_orders
tests:
- not_null:
column_name: order_id
where: "order_date >= current_date - 7" # rolling window
- relationships:
to: ref('dim_customer')
field: customer_id
where: "is_active = true"
Skip the biggest tests on every run and execute them nightly: dbt test -m tag:heavy --store-failures on a schedule.
6) Remove stale exposures and over-broad selection
Sometimes dbt build is slow because you’re building too much. Exposures and selection syntax can drag in heavy ancestors you no longer need.
- List exposures:
dbt ls --resource-type exposure. Kill ones pointing at a dead dashboard or retired report. - Audit selectors: prefer
state:modifiedover global+on top-level packages; scope to folders:dbt build -s tag:core models/marts/+. - When you run dbt in CI, use
--defer --state target/to avoid rebuilding unchanged ancestors.
7) Right-size threads and isolate critical models
How many models are executing in parallel? Threads defines potential concurrency; the DAG defines reality. Increase threads until the warehouse saturates, then stop.
# one-off
DBT_THREADS=16 dbt build -m <critical_path_first>+ --profiles-dir .
# profiles.yml (example)
outputs:
prod:
type: snowflake
threads: 12
Pin a few heavyweight models to their own queue or bigger warehouse using a tag and a separate job. That isolates the true bottlenecks without overprovisioning everything.
Warehouse tuning (useful, but usually the smaller half)
Yes, compute matters—but fix shape first. Then:
- Snowflake: pick a warehouse size that completes your critical path without spilling; consider auto-suspend/auto-resume for cost control.
- BigQuery: watch slot consumption and bytes processed; consider reservations for peak windows.
- Databricks: ensure cluster autoscaling is on; checkpoint long pipelines; cache key Dimension tables if they’re reused.
Measure before/after with the same critical path and note execution time deltas only after project-level fixes.
FAQ from operators
Are your dbt runs taking forever? Is this normal execution time and is expected?
No. If a single 40M-row fact on an X-Small warehouse takes longer than a similar ad-hoc warehouse-native transform, your dbt shape is likely the issue. Start with the critical path and fixes above.
Can dbt recommend refactoring models which share a number of ancestors?
Not automatically. But the manifest contains the Graph you need. The Python snippet above identifies shared hot ancestors; those are prime candidates to materialize as tables.
Future: A Native dbt CLI Feature?
Today, dbt Labs exposes artifacts; community tools and small scripts fill the gap. Track the dbt community forum and the GitHub repo for updates. A well-known GitHub · issue discusses compile/run performance, but you don’t need to wait for core changes to get wins.
How can I confirm my incremental model is avoiding full table scans?
Check your warehouse’s query profile for the model’s compiled SQL: look for partition pruning, clustering usage, and limited scanned bytes. If scans spike on a day when you didn’t change logic, you likely triggered a full-refresh or lost the filter.
How do I find the few dbt models driving most of my compute cost?
Sort models by elapsed in run_results.json (command above). Cross-reference with warehouse bytes scanned or shuffle to see the true cost drivers.
How many models are executing in parallel?
Check --threads (CLI or profiles.yml). The DAG’s dependency structure caps parallelism; if Node B depends on Node A, they will not execute together. Improving concurrency usually means breaking long chains or materializing shared ancestors.
Is there a way to detect and categorize certain types of transformations from the shape of a dbt graph?
Yes. Using manifest metadata, you can label nodes by materialization, fan-out count, and reuse frequency. That’s enough to build a simple Algorithm that flags likely hotspots.
What configurations can we make besides creating our own separate schema, in order to shorten this 15 min schema parsing?
Limit packages, disable unused seeds, scope selectors, and reduce SELECT * width. Docker cold starts and network filesystem latency can also slow parsing; keep dependencies cached in CI.
What is Microbatch incremental strategy?
It’s a pattern of processing small time-sliced batches in an incremental loop (e.g., per-hour windows) to control memory and retries. dbt’s built-in strategies are merge/append/delete+insert; microbatching is an orchestration pattern you can implement with variables and a for-loop in your job runner.
Slow dbt Runs? queries?
If you use dbt in Airflow or Docker and it feels slow at startup, you may be hitting environment overhead. The fixes above still apply; when it’s finished running, compare model timings to isolate project vs. container issues.
Helpful references and next steps
- dbt docs: run_results.json, incremental config
- Related reading from us: dbt articles and warehousing articles
If your dbt project still feels slow, we do this work weekly. See our dbt repo performance audit, plan a dbt Cloud migration if orchestration is part of the pain, or get fast help: start a project with Vertex.
Operator checklist (do this now):
- Run the two commands above to list slow models and your critical path.
- Make one incremental conversion and remove one SELECT *.
- Materialize one hot ancestor as a table and re-run with
--threadstuned.
Then measure again. This is how you make dbt fast, safely.
PS: We hang out in the dbt community and see these patterns every week. If you want production-grade help on warehouses, analytics pipelines, Airflow engineering, Slack-native agents, or Business Health dashboards, we’re here.
Notes: This guidance comes from building platforms at scale—not from theory. It’s compatible with dbt’s best practices and works whether your Warehouse is Snowflake, BigQuery, or Databricks.
Brand aside: As Vertex Data Consulting, we care about the vertices and edges of your DAG. Fix the Vertex, fix the Graph.
Finally, remember: dbt’s artifacts tell you where to optimize; dbt Labs continues to improve core performance, but you don’t need to wait for the next release to get wins.
About the author
Eric Provencio — Analytics engineer who has built and run production data platforms for Disney, Hulu, Nike, Peloton, Gopuff, and Kaplan. Founded Vertex Data Consulting to do the deep work most data teams never find time for: dbt Cloud migrations, repo performance, Airflow reliability, and AI agents that actually touch the stack.