Our CI got slower and more expensive at exactly the same rate our test suite got better, and for a while nobody connected the two. We’d done everything the best-practice blog posts told us to: added dbt unit tests to lock down the gnarly transformation logic, wired them into CI so every pull request ran them, felt good about ourselves. Then the Snowflake bill for the CI account crept up, and up, and the person who opened it asked the reasonable question: “Why is *testing* our most expensive warehouse workload?”

Because dbt unit tests, for all their software-engineering framing, are not free the way unit tests in a normal codebase are free. A Python unit test runs in memory on the machine you’re already paying for. A dbt unit test compiles to SQL and runs on a Snowflake warehouse — and at the scale of a real suite, run on every pull request, that’s a lot of warehouse-seconds you didn’t budget for. This is the honest accounting of where that cost comes from, and how to keep the safety net without the bill.

One clarification up front, because the word “test” covers two very different things in dbt and conflating them muddies the whole cost conversation. This article is about unit tests â€” the dbt v1.8+ feature that validates your transformation logic against mock inputs — not data tests (not_nullunique, and friends) that query your real tables. Both cost credits, but they cost them differently, and unit tests have a hidden cost that surprises people.

TL;DR

→ dbt unit tests validate transformation logic with static mock inputs. But they don’t run in memory — each compiles to a real SQL query that executes on a Snowflake warehouse. Hundreds of tests × every CI run × every PR = real, recurring credit burn.

→ The hidden cost: a unit test’s direct parent models must exist in the warehouse before the test can run. Naively, that means building upstream models just to test one — you pay to materialize parents you don’t care about.

→ The fix for that specific trap: the --empty flag builds empty (zero-row) versions of the parent models, so they exist for the test to reference without paying to populate them.

→ dbt Labs is explicit: only run unit tests in development and CI, never in production. The inputs are static, so production runs burn compute for zero added signal.

→ Warehouse mechanics amplify it: Snowflake bills a 60-second minimum every time a warehouse resumes, so a suite that resumes the warehouse repeatedly pays that floor over and over.

→ The real levers: run only state:modified+ (tests for changed models, not the whole suite), use --empty parents, run on a dedicated XS warehouse with aggressive auto-suspend, and don’t unit-test logic the warehouse already guarantees (like min()).

How a dbt unit test actually runs

Here’s the thing the “just like software unit tests” framing hides. When you write a unit test in a normal language, the test harness loads your function into memory and calls it with fake arguments. Nothing leaves the machine. It’s effectively free and effectively instant.

A dbt unit test does something structurally different. dbt takes your mock input rows, your model’s SQL, and your expected output, and compiles them into a single SQL query â€” roughly, it injects your fake rows as inline literals, runs them through the actual transformation logic of the model, and compares the result to your expected rows. That compiled query then executes on your Snowflake warehouse like any other query. The “unit” is isolated in the sense that it uses mock data instead of real tables — but the execution is a genuine warehouse query, billed at the standard credit rate for the warehouse’s active time.

One query is cheap. The problem is arithmetic. A mature suite might have 300 unit tests. Run them on every pull request, and every push to every PR, across a team, and you’re issuing tens of thousands of compiled test queries a week. None is expensive alone; together they’re a line item. And unlike a data test that you might run once daily in production, unit tests fire in the tight inner loop of development, which is exactly where query volume is highest.

Dbt unit test multiplier x class=

No single test query is expensive. The multiplication across a suite, every CI run, is — and two Snowflake billing mechanics quietly amplify it.

The hidden cost nobody mentions: parent materialization

A unit test needs its parent models to exist in the warehouse first. Build them naively and you pay to materialize upstream models just to test one — unless you use –empty.

This is the trap that turns a manageable cost into a surprising one. A dbt unit test runs against a model, and that model refers to its parents via ref(). For the compiled test query to resolve, the direct parent models have to exist in the warehouse. If they don’t, the test can’t run.

The naive reaction — and the one dbt’s own docs warn about — is to just build everything upstream first: dbt build, or dbt run the parents, then test. But that means you’ve now materialized a chain of upstream models, scanning and writing real data, purely so a logic test on one downstream model has something to reference. You paid full transformation cost to set up a test that was supposed to be about logic, not data.

The intended fix is the --empty flag. Running dbt run --select "stg_orders stg_customers" --empty builds empty versions of the parent models — they exist as objects in the warehouse with the right schema but zero rows, so they cost almost nothing to create. The unit test can now resolve its ref()s against those empty parents while still using its own mock data for the actual test. If you’re running unit tests in CI without --empty, this is very likely the single biggest chunk of your test-related spend, and it’s invisible until you look at what got built versus what got tested.

The warehouse mechanics that amplify it

Two Snowflake billing details make test-suite cost worse than a naive per-query estimate suggests.

First, the 60-second minimum. Snowflake bills warehouse compute per second, but with a 60-second floor every time a warehouse resumes from suspended. A single fast test query that takes two seconds still bills a full minute if it resumed a cold warehouse. If your CI pattern lets the warehouse suspend and resume repeatedly across a run, you pay that one-minute floor multiple times for work that totaled seconds.

Second, warehouse size is usually the wrong knob to reach for, but people reach for it anyway. Teams default to a LARGE or XLARGE warehouse “to be safe,” but unit tests operate on tiny mock datasets — a handful of rows. There is nothing for a big warehouse to parallelize. You’re paying 8x or 16x the per-second rate for a workload that an XSMALL handles identically. For unit testing specifically, warehouse size is close to pure waste above XSMALL.

The cost math, concretely

Let’s put rough numbers on it. Say you have 300 unit tests, a team of 6 engineers, and CI runs on every push. A realistic week might see 200 CI runs. If each run executes the full suite and — because nobody set up --empty â€” also materializes a chunk of the upstream DAG each time, you’re looking at hundreds of thousands of query-seconds plus repeated 60-second warehouse-resume floors.

Even at a modest XSMALL (1 credit/hour), the repeated resume floors alone add up: 200 runs a week that each cold-start the warehouse is 200 minutes — over 3 hours — of billed time that did almost no work. Add the parent materializations at full data volume and the number climbs fast. Now imagine someone “played it safe” with a MEDIUM warehouse (4 credits/hour): same work, 4x the bill. None of this bought you better tests. It bought you the same tests, slower to notice and more expensive to run.

The reframe that matters: unit-test cost scales with how you run the suite, not with how good your tests are. Two teams with identical test coverage can have a 10x difference in test spend based purely on --empty, warehouse size, and whether they run the whole suite or just what changed.

Keeping the safety net without the bill

Dbt unit test levers 1 x class=

Two teams with identical coverage can differ 10x in spend. These are the knobs that account for the gap, biggest win first.

Run only what changed. The biggest lever by far. In CI you rarely need the whole suite — you need the tests affected by this pull request. dbt’s state comparison lets you select state:modified+ to run only modified models and their downstream dependents. On a big project, a typical PR touches a handful of models, so this turns a 300-test run into a 15-test run. Same protection for the change at hand, a fraction of the queries.

Always build parents with --empty. Make it the default in your CI script, not an optimization you remember sometimes. Empty parents give the tests something to reference without paying to populate upstream models. This is the fix for the hidden cost above, and it’s a one-flag change.

Use a dedicated XSMALL CI warehouse with aggressive auto-suspend. Size it down — unit tests don’t benefit from more compute. Give it its own warehouse so test runs don’t tangle with analyst queries or production jobs, which also makes the cost trivially easy to attribute. Set auto-suspend low so it doesn’t idle-bill after the run, but be aware of the flip side: too-frequent suspend/resume cycles trigger the 60-second floor repeatedly, so tune it to your CI cadence rather than blindly to the minimum.

Never run unit tests in production. dbt Labs is unambiguous here, and it’s worth internalizing why: unit test inputs are static mock data, so the result is identical every time regardless of what’s in production. Running them against prod burns compute to re-confirm something that cannot have changed. Unit tests belong in development (test-driven work) and CI (catching regressions before merge) — full stop.

Don’t test what the warehouse already guarantees. dbt Labs recommends against unit-testing built-in functions like min() â€” they’re already exhaustively tested by Snowflake, and a fixture that checks them tells you nothing while still costing a query. Aim unit tests at logic that actually has edge cases: custom categorization, window functions, business rules, things you’ve had bugs in before. Every test you don’t write because it adds no signal is a query you never pay for.

The gotchas nobody warns you about

Incremental models need to exist before you unit-test them. Like other parents, an incremental model must be present in the database before its unit test runs, and the expected output of a unit test on an incremental model is the result of the materialization step (what gets merged/inserted), not the final table state. Build it with --empty first, same as any parent, and be precise about what you’re asserting.

Ephemeral parents force a format change. If the model under test depends on an ephemeral model, you can’t reference it the usual way — you have to provide that input as raw SQL (format: sql) in the test fixture. Not a cost issue, but a “why won’t this run” issue that eats debugging time.

The suite’s cost is invisible in aggregate dashboards. Test queries blend into total warehouse spend and look like noise. To actually see them, tag your CI runs — dbt’s query-comment with the invocation_id lets you filter QUERY_HISTORY to a single test run and total its cost. If you can’t measure per-invocation test spend, you can’t tell whether your fixes worked.

“Add more tests” has a non-zero marginal cost here. In a normal codebase, adding a unit test is free forever after. In dbt, every unit test you add is a query that runs on every relevant CI invocation for the life of the project. That’s not a reason to skip tests — it’s a reason to be deliberate: test high-value logic, skip the trivial, and let state:modified+ keep the per-run count proportional to the change, not the suite.

The one principle

A dbt unit test is not an in-memory assertion; it’s a warehouse query with a prerequisite. Treat it like one. Build parents empty, run only what changed, size the warehouse down, keep it out of production, and test logic that actually has edge cases. The goal isn’t fewer tests — it’s a test suite whose cost tracks the size of your changes, not the size of your suite. Do that, and unit testing stays the cheap safety net it’s supposed to be instead of the line item that makes someone ask why testing is your priciest workload.

Related reading: dbt: Unit tests (official docs) Â· Understanding costs for dbt Projects on Snowflake Â· dbt State on Snowflake: Skip Unchanged Models Â· Debugging Zero-Copy Clone Storage Costs in CI/CD Â· Orchestrating dbt With Airflow on Snowflake