dbt Performance Tuning

As a dbt project grows, run times increase. A project that once finished in five minutes may take an hour after adding hundreds of models and millions of rows. Performance tuning is the process of identifying slow models, understanding why they are slow, and applying the right fixes to make runs fast again.

Where Time Goes in a dbt Run

Time is spent in three places:

1. Warehouse compute time
   The SQL query runs slowly inside the warehouse.
   Fix: optimise the SQL, change materialisation, add clustering.

2. Model count
   Too many models rebuild from scratch every run.
   Fix: use incremental materialisation or ephemeral for intermediates.

3. Sequential bottlenecks
   A slow parent model blocks all children from starting.
   Fix: break the slow model into smaller parallel models or optimise the parent.

Finding Slow Models

From the Terminal

dbt prints run time per model at the end of every run. Look for models taking more than a few minutes:

$ dbt run

1 of 12 OK  stg_orders ...... [OK in 0.3s]
2 of 12 OK  stg_customers ... [OK in 0.2s]
3 of 12 OK  int_orders_joined [OK in 47.3s]   ← slow
4 of 12 OK  fct_orders ...... [OK in 8.1s]

From dbt Cloud Run History

The Run History tab in dbt Cloud shows a visual timeline of model run durations. Sort by duration to immediately surface the slowest models.

From run_results.json

cat target/run_results.json | python3 -c "
import json, sys
results = json.load(sys.stdin)['results']
slow = sorted(results, key=lambda x: x['execution_time'], reverse=True)[:5]
for r in slow:
    print(f\"{r['execution_time']:.1f}s  {r['unique_id']}\")
"

Fix 1: Switch to Incremental Materialisation

The most impactful change for large tables. Instead of rebuilding millions of rows every run, process only new rows:

Before (full refresh every run, takes 45 minutes):
  {{ config(materialized='table') }}
  select * from {{ source('events', 'raw_events') }}

After (incremental, takes 30 seconds after first run):
  {{ config(
      materialized='incremental',
      unique_key='event_id'
  ) }}
  select * from {{ source('events', 'raw_events') }}
  {% if is_incremental() %}
    where event_at > (select max(event_at) from {{ this }})
  {% endif %}

Fix 2: Use Ephemeral for Intermediate Models

Intermediate models materialised as views re-execute their SQL every time a downstream model queries them. Using ephemeral inlines the logic as a CTE, letting the warehouse optimise the whole query at once:

Before (view: re-executed per query):
  {{ config(materialized='view') }}

After (ephemeral: inlined as CTE in the one model that uses it):
  {{ config(materialized='ephemeral') }}

Fix 3: Increase Thread Count

Threads control how many models run in parallel. More threads = more concurrent warehouse queries:

# profiles.yml
dev:
  threads: 4    ← run 4 models at once

prod:
  threads: 8    ← run 8 models at once

Increasing threads helps when many independent models exist at the same DAG level. It does not help for sequential chains where each model waits for its parent.

Fix 4: Warehouse-Specific Table Optimisations

Snowflake: Clustering Keys

{{ config(
    materialized='table',
    cluster_by=['order_date', 'region']
) }}

BigQuery: Partitioning and Clustering

{{ config(
    materialized='table',
    partition_by={
        "field": "order_date",
        "data_type": "date"
    },
    cluster_by=['region', 'status']
) }}

Redshift / Postgres: Sort Keys and Distribution

{{ config(
    materialized='table',
    sort=['order_date'],
    dist='customer_id'
) }}

Fix 5: Optimise Slow SQL

Open the compiled SQL for the slow model and run it with EXPLAIN ANALYZE in your warehouse. Look for:

Problem                           Fix
-------------------------------   -----
Full table scan on large table    Add a WHERE clause to filter early
Repeated subqueries               Replace with CTEs or ref() to a materialised model
Cartesian join (missing ON)       Add the missing join condition
DISTINCT on large result set      Push DISTINCT into a subquery or use GROUP BY
Unoptimised window function       Partition by a date column to reduce sort size

Fix 6: Selector-Based Runs

In development, only run the models you are actively changing:

# Run one model and its children only
dbt run --select stg_orders+

# Run only changed models (requires state comparison)
dbt run --select state:modified+

Fix 7: Defer Unchanged Models (CI)

In CI, use --defer so unchanged models read from the production schema instead of rebuilding:

dbt build --select state:modified+ --defer --state ./prod-artifacts

Performance Tuning Checklist

[ ] Identify slowest models with run_results.json or dbt Cloud
[ ] Switch large full-refresh tables to incremental
[ ] Use ephemeral for intermediate models used by only one downstream model
[ ] Increase thread count in profiles.yml
[ ] Add clustering / partitioning to large mart tables
[ ] Review slow SQL with EXPLAIN ANALYZE
[ ] Use --select selectors in development to avoid rebuilding everything
[ ] Use --defer in CI to skip unchanged models

Leave a Comment

Your email address will not be published. Required fields are marked *