Wicked Smart Data
LearnArticlesAbout
Sign InSign Up
LearnArticlesAboutContact
Sign InSign Up
Wicked Smart Data

The go-to platform for professionals who want to master data, automation, and AI — from Excel fundamentals to cutting-edge machine learning.

Platform

  • Learning Paths
  • Articles
  • About
  • Contact

Connect

  • Contact Us
  • RSS Feed

© 2026 Wicked Smart Data. All rights reserved.

Privacy PolicyTerms of Service
All Articles
Profiling and Optimizing Slow dbt Models: Query Plans, Warehouse Resource Monitors, and Materialization Trade-offs in Snowflake and BigQuery

Profiling and Optimizing Slow dbt Models: Query Plans, Warehouse Resource Monitors, and Materialization Trade-offs in Snowflake and BigQuery

Data Engineering⚡ Practitioner23 min readJul 26, 2026Updated Jul 26, 2026
Table of Contents
  • Introduction
  • Prerequisites
  • Step 1: Stop Guessing — Start with the Compiled SQL
  • Step 2: Reading Snowflake Query Profiles
  • Step 3: Reading BigQuery Execution Details
  • Step 4: Warehouse Resource Monitors — Are You Diagnosing the Right Problem?
  • Snowflake Resource Monitors
  • BigQuery Slot Monitoring
  • Step 5: Materialization Trade-offs — The Decision That Multiplies Everything Else
  • Views: Zero Build Cost, Full Query Cost
  • Tables: Full Build Cost, Minimal Query Cost
  • Incremental: The Right Tool, Frequently Misused
  • Ephemeral: The Hidden Cost
  • Step 6: Benchmarking Your Changes
  • Suspend the cache first
  • Record query IDs and compare profiles
  • Automate the comparison
  • Hands-On Exercise
  • Setup
  • Part 1: Build the slow model
  • Part 2: Profile it
  • Part 3: Optimize
  • Common Mistakes & Troubleshooting
  • Summary & Next Steps
  • Profiling and Optimizing Slow dbt Models: Query Plans, Warehouse Resource Monitors, and Materialization Trade-offs in Snowflake and BigQuery

    Introduction

    You've built a dbt project that runs beautifully in development — models compile cleanly, tests pass, and your stakeholders are happy. Then you hit production. The full job that processes 18 months of order history now runs for 47 minutes, your warehouse costs spike at the end of every business day, and the on-call engineer gets paged because a downstream dashboard timed out again. Sound familiar?

    Slow dbt models are rarely a dbt problem. They're a data problem manifesting through dbt. The query dbt generates is just SQL, and SQL performance is governed by the same rules it always has been: how the warehouse scans data, how it joins and aggregates it, what it can skip, and how much compute you've thrown at the problem. dbt adds one more dimension: materialization strategy, which determines whether your model runs every query fresh or pre-computes results that downstream consumers can reuse. Getting optimization right means working at all three layers simultaneously — the query itself, the warehouse infrastructure, and the materialization approach.

    By the end of this lesson, you'll be able to systematically diagnose why a dbt model is slow, read query execution plans in both Snowflake and BigQuery, use warehouse-level monitoring to separate "slow query" problems from "too much concurrent load" problems, and make informed decisions about when to use incremental models, table materializations, and partitioning strategies.

    What you'll learn:

    • How to read and interpret query profiles in Snowflake's Query Profile UI and BigQuery's Execution Details
    • How to use Snowflake Resource Monitors and BigQuery Slot Reservations to diagnose infrastructure-level bottlenecks
    • The performance characteristics of dbt's four materialization types and when each one actually helps
    • Concrete strategies for optimizing incremental models with clustering keys, partition filters, and merge logic
    • How to benchmark model changes methodically so you know your "fix" actually worked

    Prerequisites

    This lesson assumes you're comfortable with:

    • Writing and running dbt models (you know what ref() and source() do)
    • Basic SQL optimization concepts like indexes, joins, and aggregations
    • Working with either Snowflake or BigQuery at an intermediate level
    • Reading dbt's compiled SQL (you know where to find it in target/compiled/)

    You do not need to be a warehouse DBA. We'll explain the warehouse-specific tooling as we go.


    Step 1: Stop Guessing — Start with the Compiled SQL

    The most common optimization mistake is trying to fix a dbt model by changing the dbt YAML or Jinja logic without ever looking at the SQL the warehouse actually executes. Before you touch anything, get the compiled SQL.

    After a dbt run, your compiled SQL lives in target/compiled/<project_name>/models/<path>/<model_name>.sql. For incremental models, this shows the SELECT statement dbt uses for the incremental load, but it does not show the final merge or insert-overwrite statement. To see that, look in target/run/<project_name>/models/<path>/<model_name>.sql — this file contains the full DDL that dbt executed, including the MERGE or INSERT OVERWRITE wrapper.

    Copy that SQL out of the target/run/ file. Paste it directly into your warehouse query editor. Now you're working with reality, not an abstraction.

    Here's a realistic example of what you might find when you open that file for a slow incremental model:

    -- target/run/my_project/models/marts/fct_orders.sql
    merge into "ANALYTICS"."MARTS"."FCT_ORDERS" as DBT_INTERNAL_DEST
        using (
            select
                o.order_id,
                o.customer_id,
                o.order_date,
                o.status,
                sum(oi.quantity * oi.unit_price) as order_total,
                count(oi.line_item_id) as line_item_count
            from "ANALYTICS"."STAGING"."STG_ORDERS" o
            join "ANALYTICS"."STAGING"."STG_ORDER_ITEMS" oi
                on o.order_id = oi.order_id
            where o.updated_at >= (
                select max(updated_at) from "ANALYTICS"."MARTS"."FCT_ORDERS"
            )
            group by 1, 2, 3, 4
        ) as DBT_INTERNAL_SOURCE
        on DBT_INTERNAL_SOURCE.order_id = DBT_INTERNAL_DEST.order_id
        when matched then update set ...
        when not matched then insert ...
    

    This SQL has at least three potential performance problems visible immediately: a correlated subquery inside the WHERE clause that re-scans the target table, a join between two staging tables with no obvious filter on stg_order_items, and a MERGE operation that requires Snowflake (or BigQuery) to match every source row against every target row. Let's learn how to confirm whether these are actually the bottlenecks.


    Step 2: Reading Snowflake Query Profiles

    Snowflake's Query Profile is one of the most powerful diagnostic tools in the modern data stack, and most people only glance at it. Here's how to actually use it.

    Run your compiled SQL directly in the Snowflake worksheet. After it completes, click "Query ID" in the results pane, then click "Query Profile" in the top-right corner. Alternatively, go to Activity > Query History, find your query, and click the Query ID link.

    The Query Profile shows a directed acyclic graph (DAG) of query operators. Each node in the graph is a physical operation the warehouse performed: a table scan, a join, an aggregation, a sort. The nodes are colored and sized to reflect their relative cost.

    The three numbers to look at first:

    1. Bytes scanned — shown at the TableScan nodes. This tells you how much raw data Snowflake had to read. If you're scanning 500GB for a query that produces 10,000 rows, something is wrong upstream.

    2. Partitions scanned vs. total partitions — this ratio is critical. Snowflake automatically partitions data via micro-partitions. If your query scanned 4,800 out of 4,800 partitions, you have zero partition pruning. This is the most common source of slowness in Snowflake models and it usually means your WHERE clause isn't filtering on the column Snowflake uses to order the micro-partitions.

    3. Spillage to local/remote disk — look for "Spilling" labels on Join or Aggregate nodes. When Snowflake can't fit an operation in memory, it spills to local disk (tolerable) or remote disk (very expensive). Remote disk spill is the single most actionable warning sign in a query profile.

    Warning: Disk spillage in Snowflake almost always means your virtual warehouse is undersized for this specific operation, not that the whole warehouse needs to be larger. A complex GROUP BY over 200M rows might require a Large warehouse while most of your other models run fine on Small. Scaling up for spillage is sometimes correct, but first check whether the query itself can be rewritten to reduce the data volume at the spilling step.

    Finding the expensive operator:

    Click each node in the query DAG. The right panel shows timing, row counts in vs. out, and bytes processed. A join node that takes in 50M rows on the left side and 80M rows on the right side and produces 400M rows out is almost certainly a fan-out problem — you have a many-to-many join where you expected many-to-one. This is a logical bug, not a performance tuning problem, and no amount of warehouse resizing will fix it.

    Look for nodes where rows out >> rows in. That's fan-out. Look for nodes where rows out << rows in but bytes scanned is still huge. That's a missing filter — Snowflake had to read all the data just to throw most of it away.


    Step 3: Reading BigQuery Execution Details

    BigQuery handles performance diagnostics differently because it's a serverless, slot-based engine. You don't size a warehouse — instead, BigQuery allocates slots (units of compute) to your query dynamically, or from a reservation if you're on flat-rate pricing.

    To find the execution details, run your query in the BigQuery console. After it completes, click "Execution Details" in the results panel (not "Results" — the tab next to it). You'll see a breakdown of query stages.

    BigQuery decomposes your query into stages, each of which is a set of operations that can run in parallel. Each stage shows:

    • Input rows and output rows — same fan-out diagnostic as Snowflake
    • Slot time consumed — the total compute time across all parallel workers, in milliseconds. A stage that consumes 450,000ms of slot time but completes in 3 seconds is using ~150 parallel slots. A stage consuming 450,000ms that takes 450 seconds is running on a single slot, which usually indicates a shuffle bottleneck.
    • Shuffle bytes — data transferred between stages. High shuffle bytes indicate that BigQuery had to redistribute data across workers, which is expensive. Joins on low-cardinality or unevenly distributed keys cause shuffle imbalance (one worker processes 70% of the rows).

    The most expensive stage is usually a JOIN or a GROUP BY on a large table. In BigQuery, look for stages where the output bytes are dramatically smaller than the input bytes but the slot time is enormous — that's a sign you're filtering late in the plan rather than early.

    Tip: BigQuery's query planner is generally good at predicate pushdown, but it can be fooled by non-deterministic functions, certain CASE expressions, and subqueries that reference session variables. If you see a full table scan on a partitioned table, check whether your partition filter is wrapped in a function like DATE() or TIMESTAMP_TRUNC() that prevents BigQuery from recognizing it as a partition-pruning predicate.

    Understanding bytes billed vs. bytes processed:

    BigQuery bills on bytes processed, and a query's billing is determined at plan time, not at execution time. A query that scans a 2TB table but returns 10 rows still costs you 2TB. Partition pruning and column projection (only selecting the columns you need) are the two primary cost levers in BigQuery.

    To check whether your dbt model is doing efficient column projection, look at the compiled SQL. If you're doing SELECT * at any stage — even inside a CTE — you're pulling every column through the execution plan until BigQuery can prune it. BigQuery is columnar, so this is less catastrophic than in a row store, but it still increases shuffle bytes unnecessarily.


    Step 4: Warehouse Resource Monitors — Are You Diagnosing the Right Problem?

    Before you spend hours optimizing query logic, make sure your slow model isn't slow because of contention rather than query complexity. A query that runs fine in isolation at 10am might run for 3x as long at 6pm when eight other models are running concurrently.

    Snowflake Resource Monitors

    Snowflake Resource Monitors are primarily a cost-control tool, but the Query History UI is where you diagnose contention. Go to Activity > Query History and set the filter to your warehouse name and a time window that covers your slow run. Sort by "Queued" time.

    If queries are spending significant time in "Queued" state — meaning the warehouse accepted the query but couldn't start it because all threads were busy — you have a concurrency problem, not a query-complexity problem. The solution is different:

    • For concurrency problems: Use multi-cluster warehouses in Snowflake (set auto_suspend and min_cluster_count/max_cluster_count in your warehouse config), or split your dbt job into separate warehouse targets for heavy models vs. light models.
    • For query-complexity problems: Optimize the SQL, adjust materialization, add clustering keys.

    In your dbt profiles.yml or in model-level configs, you can direct specific models to different warehouses:

    # models/marts/fct_orders.yml
    models:
      - name: fct_orders
        config:
          snowflake_warehouse: TRANSFORM_LARGE
    

    This means your heavyweight fact table rebuild runs on a Large warehouse while your staging models run on a Small warehouse, without changing anything else in your pipeline.

    BigQuery Slot Monitoring

    In BigQuery, navigate to the BigQuery console, then to Administration > Resource Management (or use the BigQuery Admin project if you're on flat-rate). The Slot Utilization chart shows you whether your project is consistently maxed out on available slots.

    If slot utilization is at 100% for extended periods during your dbt run, your queries are queuing behind each other. Increasing slot reservations or spreading model execution across time windows will help more than query optimization will.

    For on-demand BigQuery (no flat-rate), each project gets 2,000 concurrent slots by default. You can monitor per-job slot usage with this query against the INFORMATION_SCHEMA:

    select
        job_id,
        query,
        total_slot_ms,
        total_bytes_processed,
        total_bytes_billed,
        creation_time,
        end_time,
        timestamp_diff(end_time, creation_time, second) as duration_seconds
    from `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
    where creation_time >= timestamp_sub(current_timestamp(), interval 24 hour)
        and job_type = 'QUERY'
        and state = 'DONE'
    order by total_slot_ms desc
    limit 50
    

    This gives you your 50 most slot-intensive queries from the past 24 hours. Match these against your dbt model run times (which you can get from dbt's run_results.json artifact) to identify which specific models are consuming disproportionate resources.


    Step 5: Materialization Trade-offs — The Decision That Multiplies Everything Else

    dbt's four materializations — view, table, incremental, and ephemeral — aren't just implementation choices. They fundamentally change how query cost flows through your pipeline. Understanding the trade-offs means understanding that you're always trading build cost against query cost.

    Views: Zero Build Cost, Full Query Cost

    A view has zero build cost — dbt just creates a SQL view definition. But every time a downstream model or BI tool queries that view, it re-executes the full underlying SQL. If you have a complex view that joins three tables and performs multiple window functions, and that view is referenced by 12 downstream models, you're running that expensive SQL 12 times per dbt job plus every dashboard query.

    Views are correct when:

    • The underlying data is small (< a few million rows)
    • The view is queried infrequently (not referenced by many downstream models in the same job)
    • You need the data to always be fresh (no lag between source data and view results)

    Views are a trap when:

    • You stack views on top of views (Snowflake and BigQuery both dereference nested views into a single flat query at execution time, which can be extremely expensive)
    • You use them as a staging layer that's referenced by every downstream model in your project

    Tables: Full Build Cost, Minimal Query Cost

    A table materialization runs the full model SQL on every dbt run and replaces the entire table. This is expensive to build but cheap to query. Downstream consumers always read from a pre-computed, indexed/clustered physical table.

    Tables become the right choice when:

    • The model is referenced frequently by downstream models or BI tools
    • The model is computationally expensive to compute on the fly
    • You need consistent query performance regardless of underlying data volume changes

    The failure mode of table is when people apply it to models that are expensive to build and that only change a small fraction of their data each run. A fact table with 3 years of order history that only adds today's rows doesn't need to be fully rebuilt every night. That's what incremental is for.

    Incremental: The Right Tool, Frequently Misused

    Incremental models are the most powerful and most misused materialization in dbt. The idea is simple: on the first run, build the full table. On subsequent runs, only process new or changed rows and merge them in. The performance gain can be enormous — processing 10,000 new rows instead of rebuilding 500M rows.

    But incremental models have two failure modes that make them slower than tables in practice:

    Failure Mode 1: The incremental filter isn't actually selective.

    The standard dbt incremental pattern looks like this:

    -- models/marts/fct_orders.sql
    {{
        config(
            materialized='incremental',
            unique_key='order_id'
        )
    }}
    
    select
        o.order_id,
        o.customer_id,
        o.order_date,
        o.status,
        sum(oi.quantity * oi.unit_price) as order_total,
        count(oi.line_item_id) as line_item_count
    from {{ ref('stg_orders') }} o
    join {{ ref('stg_order_items') }} oi
        on o.order_id = oi.order_id
    {% if is_incremental() %}
        where o.updated_at > (select max(updated_at) from {{ this }})
    {% endif %}
    group by 1, 2, 3, 4
    

    The problem here is that stg_order_items is not filtered at all. You're joining all of stg_orders (filtered) against all of stg_order_items (not filtered). For a fact table with 100M order items, that join still scans the full items table on every incremental run.

    Fix it by filtering both sides:

    {% if is_incremental() %}
    with recent_orders as (
        select order_id
        from {{ ref('stg_orders') }}
        where updated_at > (select max(updated_at) from {{ this }})
    ),
    
    recent_items as (
        select oi.*
        from {{ ref('stg_order_items') }} oi
        inner join recent_orders ro on oi.order_id = ro.order_id
    )
    
    select
        o.order_id,
        o.customer_id,
        o.order_date,
        o.status,
        sum(ri.quantity * ri.unit_price) as order_total,
        count(ri.line_item_id) as line_item_count
    from {{ ref('stg_orders') }} o
    join recent_items ri on o.order_id = ri.order_id
    where o.order_id in (select order_id from recent_orders)
    group by 1, 2, 3, 4
    
    {% else %}
    
    select
        o.order_id,
        o.customer_id,
        o.order_date,
        o.status,
        sum(oi.quantity * oi.unit_price) as order_total,
        count(oi.line_item_id) as line_item_count
    from {{ ref('stg_orders') }} o
    join {{ ref('stg_order_items') }} oi
        on o.order_id = oi.order_id
    group by 1, 2, 3, 4
    
    {% endif %}
    

    Failure Mode 2: The MERGE operation scans the entire target table.

    In both Snowflake and BigQuery, a MERGE statement has to match source rows against target rows. If the target table has 500M rows and you're merging in 10,000 new rows, Snowflake may still scan all 500M target rows looking for matches — unless there's a clustering key or partition specification that lets the warehouse skip irrelevant data.

    In Snowflake, solve this with a cluster_by config that matches your unique_key lookup pattern:

    # models/marts/fct_orders.yml
    models:
      - name: fct_orders
        config:
          materialized: incremental
          unique_key: order_id
          cluster_by: ['order_date']
    

    Clustering by order_date means the most recent orders are co-located in the same micro-partitions. When the MERGE goes looking for matching order_id values among recent orders, Snowflake can skip all the micro-partitions containing old orders.

    In BigQuery, solve this with partitioning and a merge_update_columns strategy that includes the partition column:

    # models/marts/fct_orders.yml
    models:
      - name: fct_orders
        config:
          materialized: incremental
          unique_key: order_id
          partition_by:
            field: order_date
            data_type: date
            granularity: day
          cluster_fields:
            - customer_id
            - status
          incremental_strategy: merge
    

    With this config, BigQuery's MERGE only scans partitions that contain rows from your source dataset's date range, instead of scanning the entire 3-year history.

    Tip: In BigQuery, always verify partition pruning is working after adding a partition config. Run the incremental query manually with EXPLAIN or check the bytes billed in the console. A common gotcha: if your source data's order_date column is a TIMESTAMP and your partition config specifies data_type: date, BigQuery may not prune partitions. Use TIMESTAMP_TRUNC(order_date, DAY) or cast explicitly.

    Ephemeral: The Hidden Cost

    ephemeral models are CTEs that get inlined into every model that references them. They have zero materialization overhead — no table, no view — but every downstream model that uses them includes the full CTE computation inline. If three downstream models reference the same ephemeral model, that SQL runs three times.

    Ephemeral is correct for simple transformations that are cheap to compute and only used by one model. It's wrong for any computation you'd want to reuse, or for any computation complex enough that running it three times matters.


    Step 6: Benchmarking Your Changes

    One of the biggest mistakes in optimization work is declaring victory based on a single test run. Warehouse performance varies based on data caching, concurrent load, and micro-partition freshness (in Snowflake). Here's a rigorous benchmarking approach.

    Suspend the cache first

    In Snowflake, result caching means the second run of an identical query returns instantly from cache — not because you optimized it, but because the warehouse cached the result. Before benchmarking, run:

    alter session set use_cached_result = false;
    

    This disables result caching for your session. Now each run actually executes the query.

    For a fair comparison between an old and a new model version, run each version at least three times with cache disabled and take the median execution time. Throw out the first run if the warehouse was freshly resumed (it won't have any local data cache loaded).

    Record query IDs and compare profiles

    In Snowflake, every query execution has a unique Query ID. Record the IDs for your "before" runs and your "after" runs. You can then compare their Query Profiles side by side, looking specifically at:

    • Total execution time
    • Partitions scanned (did your clustering key actually help?)
    • Bytes scanned
    • Whether spillage disappeared

    In BigQuery, check the job information for bytes billed (before/after) and total slot ms consumed. Bytes billed is the cleanest performance metric because it's deterministic — it doesn't vary based on concurrent load.

    Automate the comparison

    dbt's run_results.json artifact (written to target/run_results.json after every run) contains per-model execution timing. For production benchmarking, you can parse this file to build a history of model execution times:

    import json
    from pathlib import Path
    from datetime import datetime
    
    results_path = Path("target/run_results.json")
    with results_path.open() as f:
        run_results = json.load(f)
    
    print(f"Run completed at: {run_results['metadata']['generated_at']}")
    print(f"\n{'Model':<50} {'Status':<12} {'Seconds':>10}")
    print("-" * 74)
    
    for result in sorted(
        run_results["results"],
        key=lambda r: r.get("execution_time", 0),
        reverse=True,
    ):
        node_id = result["unique_id"].split(".")[-1]
        status = result["status"]
        execution_time = result.get("execution_time", 0)
        print(f"{node_id:<50} {status:<12} {execution_time:>10.2f}s")
    

    Run this after each experiment to get a ranked view of model execution times. Compare the output before and after your optimization.


    Hands-On Exercise

    In this exercise, you'll take a deliberately slow incremental model, profile it, and optimize it using the techniques in this lesson.

    Setup

    You'll need a Snowflake or BigQuery environment with dbt configured. Create the following source tables (adjust syntax for your warehouse):

    -- Create staging tables with enough rows to see performance differences
    -- In Snowflake:
    create or replace table analytics.staging.stg_events as
    select
        seq4() as event_id,
        uniform(1, 1000000, random()) as session_id,
        uniform(1, 50000, random()) as user_id,
        dateadd(second, uniform(0, 60*60*24*365, random()), '2022-01-01'::timestamp) as event_timestamp,
        case uniform(1, 5, random())
            when 1 then 'page_view'
            when 2 then 'click'
            when 3 then 'purchase'
            when 4 then 'signup'
            else 'logout'
        end as event_type,
        uniform(1, 1000, random())::float / 10 as event_value
    from table(generator(rowcount => 50000000));
    
    create or replace table analytics.staging.stg_sessions as
    select
        seq4() as session_id,
        uniform(1, 50000, random()) as user_id,
        dateadd(second, uniform(0, 60*60*24*365, random()), '2022-01-01'::timestamp) as session_start,
        case uniform(1, 3, random())
            when 1 then 'web'
            when 2 then 'mobile'
            else 'api'
        end as channel
    from table(generator(rowcount => 1000000));
    

    Part 1: Build the slow model

    Create this model in your dbt project at models/marts/fct_user_sessions.sql:

    {{
        config(
            materialized='incremental',
            unique_key='session_id'
        )
    }}
    
    select
        s.session_id,
        s.user_id,
        s.session_start::date as session_date,
        s.channel,
        count(e.event_id) as event_count,
        sum(case when e.event_type = 'purchase' then e.event_value else 0 end) as purchase_value,
        count(distinct e.event_type) as unique_event_types
    from {{ ref('stg_sessions') }} s
    left join {{ ref('stg_events') }} e
        on s.session_id = e.session_id
    {% if is_incremental() %}
        where s.session_start >= (
            select dateadd(day, -1, max(session_start)) from {{ this }}
        )
    {% endif %}
    group by 1, 2, 3, 4
    

    Run it once to build the full table (dbt run --full-refresh --select fct_user_sessions), then run it again incrementally. Time the incremental run.

    Part 2: Profile it

    Open the compiled SQL from target/run/. Paste it into your warehouse query editor and run it. Open the Query Profile (Snowflake) or Execution Details (BigQuery). Answer these questions:

    1. How many partitions/bytes were scanned on stg_events?
    2. Is there spillage in any join or aggregate nodes?
    3. What is the row count entering and exiting the join node?

    Part 3: Optimize

    Apply the following changes:

    1. Add a cluster_by: ['session_date'] config (Snowflake) or partition_by on session_start (BigQuery)
    2. Fix the incremental logic so that stg_events is also filtered to only recent sessions
    3. Run dbt run --full-refresh to rebuild with new clustering, then run incrementally again

    Compare the Query Profile before and after. You should see a significant reduction in partitions scanned on stg_events.


    Common Mistakes & Troubleshooting

    "I added clustering but it's still scanning all partitions"

    Clustering in Snowflake takes time to take effect on existing data. After you add a cluster_by config and run --full-refresh, Snowflake needs to reorganize the micro-partitions in the background. Check the clustering state with:

    select system$clustering_information('ANALYTICS.MARTS.FCT_USER_SESSIONS', '(session_date)');
    

    If average_depth is greater than ~2, the data isn't well-clustered yet. This can take minutes to hours on large tables. You can also explicitly call:

    alter table ANALYTICS.MARTS.FCT_USER_SESSIONS cluster by (session_date);
    

    "My incremental model runs fine but produces wrong results after a late-arriving data fix"

    The lookback window in your incremental filter (dateadd(day, -1, max(...))) determines how far back you reprocess. If source data corrections go further back than your lookback window, those corrections won't be picked up. Solutions:

    • Widen the lookback window (but this increases processing cost)
    • Use dbt run --full-refresh on a schedule (e.g., weekly) to catch any corrections
    • Design your source data pipeline to set updated_at to the current timestamp when corrections are made, so the filter catches them

    "My view-stacked-on-view model is timing out"

    Three views deep is often where Snowflake and BigQuery start to produce query plans with 10+ table scans because every view dereferences all its upstream views into a flat plan. The fastest fix is to materialize the most-referenced middle layer as a table. Use dbt's --store-failures option and query history to identify which model in your chain is referenced most.

    "BigQuery says 0 bytes processed but the query still took 30 seconds"

    This is BigQuery's result cache. The 0 bytes is accurate — BigQuery retrieved the result from cache and didn't scan anything. But the 30-second latency is coming from query slot scheduling overhead and result retrieval. For dashboards that need sub-5-second latency, consider BigQuery BI Engine reservations or materializing results into a smaller summary table.

    "dbt's MERGE is slower than replacing the whole table"

    This actually happens. When your incremental batch is large (>20% of the target table), a MERGE operation touches most of the table anyway and adds the overhead of the merge logic on top. In this case, switching to incremental_strategy: insert_overwrite (BigQuery) or using a partition-based replace strategy can be faster than a row-level MERGE. In dbt, set:

    config:
      materialized: incremental
      incremental_strategy: insert_overwrite
      partition_by:
        field: order_date
        data_type: date
    

    This tells dbt to delete and replace entire partitions rather than merging row-by-row, which BigQuery can do extremely efficiently.


    Summary & Next Steps

    You now have a systematic approach to diagnosing and optimizing slow dbt models. The workflow looks like this: start with the compiled SQL in target/run/, profile it in the warehouse's native tooling (Query Profile for Snowflake, Execution Details for BigQuery), check whether you're solving a query-complexity problem or a concurrency problem using Query History and Resource Monitor data, then apply the right fix.

    The most impactful optimizations in order of typical return:

    1. Fix fan-out joins — these are logical bugs that no amount of infrastructure will solve
    2. Ensure partition/micro-partition pruning — both in the incremental filter and in the MERGE target scan
    3. Filter both sides of incremental joins — don't let the "non-filtered" side of a join negate your incremental savings
    4. Choose the right materialization — tables for heavily-referenced models, incremental with proper clustering for large fact tables, not views for anything compute-intensive
    5. Right-size and right-assign warehouses — use model-level warehouse configs to match compute to workload

    To go deeper from here:

    • Explore dbt's on_schema_change config for incremental models — it controls what happens when you add or remove columns, and the wrong setting can silently drop columns in production
    • Learn about Snowflake's Dynamic Tables, which offer an alternative to incremental models with automatic refresh management
    • Investigate BigQuery Materialized Views for cases where you want pre-computed results that BigQuery manages automatically, without dbt involvement
    • Study dbt's exposures and the dbt docs site to map which downstream consumers depend on which models — this helps you prioritize optimization work by impact

    The biggest shift in thinking this lesson asks you to make is to stop treating dbt performance as a dbt problem and start treating it as a SQL performance problem that dbt is surfacing. The warehouse is doing the work. Your job is to help it do less of it.

    Learning Path: Modern Data Stack

    Previous

    Configuring Role-Based Access Control in Snowflake and BigQuery: Warehouses, Schemas, and Row-Level Security for Analytics Teams

    Related Articles

    Data Engineering⚡ Practitioner

    Slowly Changing Dimensions in Data Pipelines: Implementing SCD Type 1, 2, and 3 with Python and SQL

    24 min
    Data Engineering🌱 Foundation

    Configuring Role-Based Access Control in Snowflake and BigQuery: Warehouses, Schemas, and Row-Level Security for Analytics Teams

    17 min
    Data Engineering🌱 Foundation

    Data Serialization Formats for Pipelines: When to Use JSON, CSV, Parquet, and Avro

    19 min

    On this page

    • Introduction
    • Prerequisites
    • Step 1: Stop Guessing — Start with the Compiled SQL
    • Step 2: Reading Snowflake Query Profiles
    • Step 3: Reading BigQuery Execution Details
    • Step 4: Warehouse Resource Monitors — Are You Diagnosing the Right Problem?
    • Snowflake Resource Monitors
    • BigQuery Slot Monitoring
    • Step 5: Materialization Trade-offs — The Decision That Multiplies Everything Else
    • Views: Zero Build Cost, Full Query Cost
    • Tables: Full Build Cost, Minimal Query Cost
    • Incremental: The Right Tool, Frequently Misused
    • Ephemeral: The Hidden Cost
    • Step 6: Benchmarking Your Changes
    • Suspend the cache first
    • Record query IDs and compare profiles
    • Automate the comparison
    • Hands-On Exercise
    • Setup
    • Part 1: Build the slow model
    • Part 2: Profile it
    • Part 3: Optimize
    • Common Mistakes & Troubleshooting
    • Summary & Next Steps