Snowflake Cost Optimization: A Practical Playbook for Data Engineers

You inherited a Snowflake environment or built one from scratch and at some point someone asked you: "Are we spending too much on this?"
If you're a solo data engineer or a small team running a production dbt project, that question lands on you. Not just the infrastructure side, but the strategy behind it. This post is the playbook I wish I had when I started optimizing our Snowflake setup: what we changed, why, and what actually moved the needle.
The Setup
Before diving into the optimizations, here's the environment we're working with:
Hundreds of dbt models across a multi-tenant schema architecture
An orchestration tool for pipeline scheduling
Three dedicated warehouses, each with a specific job:
DBT_WH— exclusive to dbt buildsA medium warehouse for overnight ELT pipelines
A medium warehouse for the web application layer (read-only queries)
Separating warehouses by workload type is the foundational decision that makes everything else possible. If you're still running dbt builds and app queries on the same warehouse, that's the first thing to fix.
Optimization 1: Right-Size Your dbt Warehouse
This was the single highest-impact change we made.
As our model count scaled into the hundreds ahead of our production go-live, we revisited warehouse sizing rather than waiting for performance problems to surface in production. We had been running dbt builds on an X-Small warehouse during development, fine for early-stage work, but not built for the full DAG. After upgrading to a Medium warehouse, build time dropped from 2 hours and 15 minutes to just over 1 hour — a ~55% reduction, and that's before production query load is even a factor.
That's not just a developer experience win. In Snowflake's credit model, a Medium warehouse costs 4x more per hour than an X-Small, but if it cuts your runtime by more than 75%, you can actually come out ahead on credits. In our case, the faster build meant less total compute time consumed, even at the higher per-hour rate.
The lesson: don't assume smaller is cheaper. Model the credits consumed, not just the warehouse size.
How to think about it
| Warehouse Size | Credits/hr | Our Build Time | Total Credits |
|---|---|---|---|
| X-Small | 1 | 2.25 hrs | ~2.25 |
| Medium | 4 | 1.0 hr | ~4.0 |
In our case the Medium is more expensive in absolute credits, but the build finishing an hour earlier means pipelines downstream can start sooner, data freshness improves, and the warehouse auto-suspends faster. The full picture matters.
Optimization 2: Increase dbt Threads
Warehouse size alone isn't enough if dbt is only building one model at a time.
By default, dbt uses a conservative thread count. We bumped ours to 8 threads, meaning up to 8 models can build in parallel at any given moment. Combined with the Medium warehouse (which has more compute to actually run those concurrent queries), this is what drove the most dramatic build time improvement.
In your profiles.yml or Snowflake connection config:
my_project:
target: prod
outputs:
prod:
type: snowflake
threads: 8
# ... rest of config
A good rule of thumb: threads should scale with your warehouse size. X-Small can handle 4 comfortably; Medium can handle 8–12 depending on your model complexity.
Optimization 3: Intermediate Model Materialization
Not all models should be tables but not all should be views either. The real work is figuring out which is which.
We audited our DAG and assigned materializations based on how each layer is actually used, not just convention. The result is a four-layer strategy:
Staging: Views. Lightweight source transformations, no need to persist them.
Intermediate: Tables — selectively. Only models referenced by multiple downstream models get materialized as tables. If an intermediate model feeds 8 marts, Snowflake would recompute it 8 times as a view, materializing it once eliminates that redundancy. Low fan-out intermediates stay as views.
Marts: Tables. The core business logic layer, fully materialized for reliability and query performance.
App consumption layer: Views built on top of marts, scoped specifically for the web application. No extra build cost, they inherit the performance of the underlying mart tables and stay easy to adjust without a full rebuild.
The rule of thumb: materialize where the fan-out is, and where the app reads. Everything in between stays as light as possible.
In dbt_project.yml, the default config — with individual model-level overrides for high fan-out intermediates:
In dbt_project.yml:
models:
my_project:
staging:
+materialized: view
intermediate:
+materialized: view # selectively overridden to table at the model level
marts:
+materialized: table
app:
+materialized: view
Optimization 4: Clustering Keys on Large Tables
For our largest fact tables, ones being queried repeatedly by the web app, we added clustering keys in Snowflake.
Without clustering, Snowflake scans the full micro-partition set when filtering. With clustering on the right columns (typically date fields and high-cardinality join keys), queries scan fewer partitions and return faster, consuming fewer credits.
In dbt, this is a model-level config:
{{ config(
materialized='table',
cluster_by=['client_id', 'effective_date']
) }}
A few things to keep in mind:
Clustering is most valuable on tables over ~1TB or with very frequent, filter-heavy queries
Snowflake automatically maintains clustering over time (Automatic Clustering), which has its own cost, monitor it
Not every table needs it, be selective
Optimization 5: Dedicated Warehouses Per Workload
We touched on this earlier, but it's worth spelling out explicitly.
Running everything on one warehouse creates two problems:
Contention: dbt builds compete with app queries, slowing both down
Blurry cost attribution: you can't tell what's expensive if it all runs on one warehouse
Our three-warehouse setup maps cleanly to three workload types:
| Warehouse | Workload | Size | Auto-Suspend |
|---|---|---|---|
DBT_WH |
dbt model builds | Medium | 60 seconds |
PIPELINE_WH |
Overnight ELT, Prefect flows | Medium | 60 seconds |
APP_WH |
Web app read queries | Medium | 10 seconds |
The app warehouse gets a shorter auto-suspend because queries are bursty and infrequent, no reason to keep it warm. The build and pipeline warehouses stay active longer during their scheduled windows.
What We Didn't Do (and Why)
A few things that often come up in cost optimization discussions that we intentionally skipped:
Query result caching: Snowflake does this automatically, no action needed
Resource monitors: Useful for hard spend caps, but we rely on warehouse-level visibility instead
Multi-cluster warehouses: Overkill for our scale; adds cost without benefit unless you have heavy concurrency spikes
The Bottom Line
Cost optimization in Snowflake isn't one big move, it's a series of intentional decisions that compound. For us, the highest-leverage changes were:
Right-sizing the dbt warehouse (X-Small → Medium)
Increasing threads to 8 for parallel builds
Reclassifying intermediate models as tables or views
Adding clustering keys to high-query-volume tables
Separating workloads into dedicated warehouses
The result: build times cut by ~55%, cleaner cost attribution by workload, and a data stack that's easier to debug when something goes wrong.
If you're running dbt on Snowflake and haven't revisited these settings since initial setup, start with threads and warehouse sizing. The ROI is immediate and the changes take about 10 minutes to implement.
Have questions about your specific setup or want to compare notes? Drop a comment below, always happy to talk data stack.



