Empty Builds as a Check
Concepts covered: Compilation
Every check so far ran on rows, written by hand or read from the warehouse. A project can also be built on no rows at all. dbt run --empty and dbt build --empty render every ref() and source() as a subquery that returns 0 rows, then create every model, view or table, with the warehouse running each statement over an empty input.
create table analytics.int_lesson_outcomes as
select
completion_id,
user_id,
score,
case
when score >= 90 then 'legendary'
when score >= 70 then 'pass'
else 'retry'
end as outcome,
score >= 70 as counts_for_streak
from (
select *
from analytics.stg_lesson_completions
where false
limit 0
) _dbt_limit_subq_stg_lesson_completionsThe statement runs to completion, so everything the warehouse checks before it reads a row gets checked: every referenced column exists, every function exists for the types it receives, every join compares comparable types, every union has matching column counts, and the role can read each relation and write each schema. A contract from the models lessons is checked too, because dbt compares the empty table's columns and types against the contract after creating it. What stays unproven is anything about rows. A data test over an empty table finds 0 failing rows and passes, an aggregate returns nothing, and a filter that drops every row looks the same as one that keeps them.
- An empty build proves
- Each model's SQL runs in the warehouse
- Columns and functions resolve for the types in play
- Contracts match the shape each model produces
- The DAG builds end to end in order
- It cannot prove
- Any data test, since 0 rows means 0 failures
- A join's keys line up
- A filter keeps the right rows
- Values fall in range
A rename, caught in a minute
A teammate renames score to points in stg_lesson_completions and opens a change with the staging model updated and nothing else. A full build reaches the failure only after building, on its real rows, every model scheduled ahead of int_lesson_outcomes, then fails there on the missing column. The empty build fails at the same model with the same message from the warehouse, that column score cannot be resolved, after reading 0 rows, and reports every model downstream as skipped. The cost is the warehouse's planning time per statement, a few seconds each, which is why the check fits before every merge where a full build does not.
| dbt build | dbt build --empty | |
|---|---|---|
| Rows read | Every row of every input | 0 |
| Time | Scales with the data | Scales with the model count |
| Catches | Bad rows, bad shape, bad logic | Bad shape |
| Data tests | Meaningful | Pass on every table |
| Unit tests | Run on fixtures | Run on fixtures |
Empty first, then unit tests
Section 0 noted that a unit test casts its given rows to the input's real column types, which means the input relation must exist. On a fresh schema nothing does, and the unit test cannot run. dbt run --empty creates every relation with the right columns and 0 rows, and dbt test --select "test_type:unit" then runs every unit test on its fixtures, so a change can be checked for shape and for logic without reading a production row. dbt build --empty does both in 1 command and also runs the data tests, which pass on empty tables and so prove nothing there; those run against real data in the scheduled build.
- A model that names a table directly, without ref() or source(), reads it in full even under --empty
- A model whose SELECT has no from clause returns its literal rows, and so can every model below it
- A model built with --empty holds 0 rows: never build that way where consumers read, and rebuild it in full after
About this lesson
This section is part of the Testing: Advanced lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.