Snowflake Performance Tuning: Diagnose Before You Resize
A practitioner’s sequence to diagnose and fix slow queries in Snowflake—spills, pruning, joins, and concurrency—plus when to resize and how to prove it.
Your queries are slow and the fix can’t just be “buy a bigger warehouse.” If you have a budget and need to decide in-house vs. help, this gives you a prioritized, step-by-step sequence: read Query Profile, analyze query history, isolate queueing vs. compilation vs. execution, then address spills, pruning, joins, and concurrency before you resize. You’ll see where query execution time actually goes, how to reduce data scanned, and when workload isolation is worth it. We connect recurring issues back to dbt model design and include a before-and-after measurement plan you can run today. This is snowflake performance tuning you can use in production, with clear trade-offs and proof points.
Start with Query Profile and ACCOUNT_USAGE: a triage order
Open one representative slow query and read its Query Profile end-to-end. In the tree, note the query plan shape, bytes at each step, and whether filters push down. Then sample 50–200 queries from Snowflake’s query history to find recurring performance bottlenecks. Work this order:
- Queueing: high
QUEUED_OVERLOAD_TIMEorQUEUED_PROVISIONING_TIMEmeans concurrency or cold start. Prove it before resizing. - Compilation: large
COMPILATION_TIME(10%+) points to massive SQL or object explosion. Fix SQL shape. - Execution: scan vs. join vs. aggregate. Track bytes and query execution time at each stage.
- Spill: local vs. remote spill nodes. Remote spill ⇢ memory pressure or skewed joins.
- Pruning: bytes scanned ≫ rows returned ⇢ a query pruning problem.
- Exploding joins: fanout and huge intermediates; DISTINCT as a band-aid.
- Warehouse saturation: cluster count pegged, time rising with concurrency.
Snowflake’s query engine is columnar and pushes predicates when it can; your job is to make that easy. If you’re new to the Profile UI, the Snowflake documentation explains each stage; read it once, then live in SQL. Most snowflake query optimization starts here.
Queueing, compilation time, and concurrency
First separate pre-execution from execution. Queueing and compilation live before the engine can start; throwing cores won’t help those. Use this rollup to quantify patterns by warehouse over the last 24 hours of query runs:
WITH q AS (
SELECT warehouse_name,
DATE_TRUNC('hour', start_time) AS hr,
COUNT(*) AS queries,
AVG(queued_overload_time/1000.0) AS avg_q_overload_s,
AVG(queued_provisioning_time/1000.0) AS avg_q_provision_s,
AVG(compilation_time/1000.0) AS avg_compile_s,
AVG(execution_time/1000.0) AS avg_exec_s
FROM snowflake.account_usage.query_history
WHERE start_time >= DATEADD('hour', -24, CURRENT_TIMESTAMP())
GROUP BY 1,2)
SELECT * FROM q ORDER BY hr, warehouse_name;
Traces to act on:
- High overload queueing with low execution: increase concurrency via multi-cluster, or split workloads.
- High provisioning time: lower autosuspend or keep one cluster warm during business hours.
- High compilation: break giant SQL into staged models or temp tables; reduce dynamic SQL.
Can caching improve Snowflake performance?
Yes—for repeated, identical snowflake queries. The result cache can return cached query results instantly; the local disk cache can speed reads. Neither helps first-run or parameterized queries that change shape. If users tweak filters constantly, focus on pruning and join shape, not caches, to optimize query performance.
Spills: local vs remote and what to change
In Query Profile, “Spill to local storage” is a warning; “Spill to remote storage” is an alarm. Remote spill adds network round-trips and throttles execution in Snowflake’s query engine. Common causes:
- Memory-heavy operations: broad
ORDER BYwithoutLIMIT, largeDISTINCT, global aggregates. - Skewed joins: a hot key makes one node hold disproportionate state.
- Unnecessary wide rows: selecting dozens of unused columns swells intermediates.
Fixes to try before resizing warehouses:
- Project only needed columns early. Push filters and
LIMITclose to the source. - Pre-aggregate facts by join grain. Join fewer rows with small dimensions first.
- Break work: materialize an intermediate table, then sort/aggregate in a second step.
- Eliminate global sorts if consumers don’t need stable order; sort in BI.
- Address skew: bucket large dimension values, or split hot keys into a separate pass.
If remote spill persists after SQL fixes, scaling or isolating the workload can be justified. Re-run the same statement after code changes and after a size bump; compare spill counts, wall time, and data scanned to show performance improvements.
Bytes scanned, query pruning, clustering, and Search Optimization
When bytes scanned dwarf rows returned, improve pruning. Reduce the amount of data each scan touches by tightening predicates to columns with good micro-partition value ranges. Prefer col BETWEEN <start> AND <end> over DATE(col) = ... so Snowflake can skip partitions.
ALTER TABLE fact_orders CLUSTER BY (order_date, customer_id);
SELECT SYSTEM$CLUSTERING_INFORMATION('FACT_ORDERS') AS info;
Use clustering info to decide if maintenance is worth it; don’t blindly recluster. For needle-in-haystack lookups, the search optimization service can accelerate point and prefix searches without scanning the whole data set—but only enable it for tables and columns that truly benefit. In OLAP-style filters, the Profile should show fewer partitions touched as you optimize queries and improve performance. If scans remain high due to unavoidable wide windows, consider denormalizing hot slices into materialized views. Measure wins in ACCOUNT_USAGE with BYTES_SCANNED and elapsed time to prove you improve the performance without sacrificing accuracy.
Join optimization: prevent exploding joins
Exploding joins show up as fanout in the Profile and ballooning intermediate rows. Typical causes: unconstrained many-to-many, mismatched data type on join keys, joining before aggregating, or a missing join condition. Help Snowflake’s query optimizer by shrinking inputs and ensuring compatibility.
-- Reduce fact before join and canonicalize data types
WITH f AS (
SELECT customer_id::NUMBER AS customer_id, order_date, SUM(amount) AS gross
FROM fact_orders
WHERE order_date BETWEEN '2026-01-01' AND '2026-01-31'
GROUP BY 1,2
), d AS (
SELECT customer_id::NUMBER AS customer_id, region
FROM dim_customers
WHERE is_active = TRUE
)
SELECT f.order_date, d.region, SUM(f.gross) AS revenue
FROM f
JOIN d USING (customer_id)
GROUP BY 1,2;
Patterns that optimize query performance:
- Semi/anti joins instead of joins-then-filter:
WHERE EXISTS (...)orWHERE NOT EXISTS (...). - Pre-aggregate bridge tables to the necessary grain before joining to facts.
- Deduplicate dimensions on the join key early; avoid late
DISTINCT. - Align data type of keys; avoid implicit casts that block predicate pushdown.
The goal is less intermediate data so the query engine spends time aggregating, not shuffling. That yields a faster query without brute-force compute.
Compilation and SQL shape: fix it in dbt
Long compilation time often comes from giant, dynamic SQL: dozens of CTEs, many UNION ALLs, or Jinja loops that expand into thousands of lines. Best practice: fix shape before compute.
- Stage work: materialize intermediates as tables or incrementals instead of one mega-SELECT.
- Parameterize with
QUERY_TAGso repeated logic doesn’t generate unique SQL each run. - Cut ephemeral chains that explode into a single statement; persist hot intermediates.
-- dbt model config: break up and persist
{{ config(materialized='table', tags=['perf']) }}
WITH cleaned AS (
SELECT * FROM {{ ref('stg_orders') }} WHERE valid_record
), summarized AS (
SELECT customer_id, DATE_TRUNC('day', order_ts) AS d, SUM(amount) AS gross
FROM cleaned GROUP BY 1,2
)
SELECT * FROM summarized;
If your repo generates unwieldy SQL, see why dbt runs get slow and warehouse-focused patterns in SQL Query Optimization for Columnar Warehouses. When it’s time for deeper cleanup, our dbt Repo Performance work gets you unstuck fast. Also see our dbt articles for design patterns that stick.
Warehouse sizing and workload isolation: when resizing wins
Once SQL, pruning, and joins are in order, compute can be the honest bottleneck. In a shared snowflake environment, isolate BI, ELT, and ad hoc so they don’t step on each other. A practical decision table:
| Signal | Action |
|---|---|
| High overload queueing, low CPU per query | Enable multi-cluster; split workloads by warehouse |
| Remote spill persists after SQL fixes | Increase size one notch; re-measure |
| Compilation dominates | Restructure SQL/dbt; resizing won’t help |
| BI refresh collides with ELT | Separate warehouses; resource monitors per team |
| Selective scans hit time SLAs | Consider Query Acceleration Service on the fact |
Keep autosuspend low and auto-resume on to optimize performance without burning credits. For performance and cost trade-offs, see Snowflake cost optimization. If you need predictable query speed for a subset of long-running analytics, isolate those models on a dedicated warehouse. Aim for optimal performance per workload, not a single warehouse that does everything.
Systematically measure wins—and bake fixes into models
Before/after is not a vibe; it’s a table. Baseline metrics tied to a stable QUERY_TAG and identical parameters. Example rollup over 7 days:
SELECT DATE_TRUNC('day', start_time) AS d,
COUNT(*) AS queries,
AVG(total_elapsed_time/1000.0) AS avg_s,
MEDIAN(total_elapsed_time/1000.0) AS p50_s,
MEDIAN(bytes_scanned) AS p50_bytes,
AVG(compilation_time/1000.0) AS avg_compile_s
FROM snowflake.account_usage.query_history
WHERE query_tag = 'daily_orders_rollup_v1'
AND start_time >= DATEADD('day', -7, CURRENT_TIMESTAMP())
GROUP BY 1 ORDER BY 1;
As you optimize queries, expect p50/p95 elapsed and BYTES_SCANNED to fall together. If time improves and bytes don’t, you likely fixed spills or joins but not pruning. If bytes drop with flat time, you may be CPU-bound and ready to resize. In dbt, make fixes stick: bridge true M:N, enforce dimensional key uniqueness, align incremental predicates with clustering, and standardize key data type. For deeper patterns, see dbt incremental models that hold up and data modeling patterns under real load.
Quick FAQ
- How do you check Snowflake performance? Start with Query Profile, then trend account usage by warehouse and tag; use the Query History UI when triaging individual statements.
- How do you handle long-running queries? Identify whether queueing, compilation, spills, or poor pruning dominates, then fix the root cause before resizing.
- How do joins get faster? Reduce inputs, define every join condition clearly, match data types, and aggregate early so Snowflake’s query optimizer has less to do.
- How does query optimization improve Snowflake performance? It lowers data scanned and memory pressure so the engine spends cycles on useful work.
- Want ongoing guardrails? Build a small dashboard from these rollups and wire resource monitors; see also Business Health Reporting.
Need help applying this in production? We’ve tuned platforms at scale. Bring a slow-query list and a week of logs. Start a project—we’ll measure, fix, and prove the delta with your workload.
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.