Module 3 — Baseline and strict#
Gate: static analysis. This is where dbt Core v2 earns its keep.
Run baseline#
Baseline is the default and the documented compatibility landing zone:
Finished 'compile' successfully for target 'dev' [3.2s]
Processed: 25 models | 61 tests | 1 snapshot | 12 seeds
Summary: 99 total | 99 success
Clean. If you stopped here you would conclude the project is in good shape.
Run strict#
[error] [InvalidPivot (dbt0432)]: Dynamic PIVOT with ANY is not supported by
dbt static analysis.
--> models/marts/agg_payment_method_mix.sql:...
Finished 'compile' with 1 error for target 'dev'
One finding, on a model that baseline passed a moment ago. Set it aside — it is a dynamic-SQL problem and it gets its own module, 3b. The rest of the nightly job is sound.
Now look at what is not in the graph#
Open any of the three quarantined models — fct_brew_yield,
dim_supplier_scorecard, agg_shop_channel_mix — and you will find the same
shape at the top:
Three models at the marts root were switched off during past incidents and never revisited. Every real project has some. Nobody knows what state they are in, because nothing has compiled them in months.
Bring them back:
[error] [UnresolvedIdentifier (dbt0227)]: Ambiguous column 'POTION_SKU' found.
Available are BREW_EVENTS.POTION_SKU, POTIONS.POTION_SKU, POTIONS.POTION_NAME,
POTIONS.POTENCY, BREW_EVENTS.BATCH_SIZE, BREW_EVENTS.SHOP_ID,
BREW_EVENTS.BREW_ID ..(and 11 more)
--> models/marts/fct_brew_yield.sql:19:5
[error] [UnresolvedIdentifier (dbt0227)]: No column SUPPLIERS.CONTRACT_START_DATE
found. Available are SUPPLIERS.CONTRACTED_SINCE, INGREDIENTS.INGREDIENT_NAME,
INGREDIENTS.UNIT_COST_GOLD, INGREDIENTS.HARVEST_SEASON, ..(and 6 more)
--> models/marts/dim_supplier_scorecard.sql:25:5
[error] [SyntaxInvalid (dbt0101)]: no viable alternative at input 'courier_owl
as ( select orders.shop_region, date_trunc('month', orders.ordered_at) as
order_month, sum(item_totals.gross_amount_gold as'
--> models/marts/agg_shop_channel_mix.sql:51:43
[error] [InvalidPivot (dbt0432)]: Dynamic PIVOT with ANY is not supported by
dbt static analysis.
--> models/marts/agg_payment_method_mix.sql:30:36
Finished 'compile' with 4 errors for target 'dev'
Four findings. Three of them are the quarantined models, each with a file, a line, and — where the analyzer can offer one — the list of columns that were available. The fourth is the pivot from a moment ago; it was already there, unrelated to quarantine, and it stays parked for Module 3b.
Each of the three quarantined models is hiding a second defect behind the one shown above — the analyzer cannot report a problem it has not reached yet. Fix them below to see what surfaces.
What baseline actually catches#
Run the same quarantine reveal at baseline instead of strict:
[warning] [SyntaxInvalid (dbt0101)]: no viable alternative at input 'courier_owl
as ( select orders.shop_region, date_trunc('month', orders.ordered_at) as
order_month, sum(item_totals.gross_amount_gold as'
--> models/marts/agg_shop_channel_mix.sql:51:43
Finished 'compile' with 1 warning for target 'dev' [3.1s]
Processed: 28 models | 61 tests | 1 snapshot | 12 seeds
Summary: 102 total | 102 success
Baseline still compiles the whole graph successfully — a syntax error is only
a warning here, not a blocker — but it is the one and only thing baseline
has any opinion about. It has nothing to say about the ambiguous column in
fct_brew_yield or the missing column in dim_supplier_scorecard, and it
cannot see the two defects hiding behind the ones it does catch. Malformed SQL
is a grammar problem; baseline parses Jinja and SQL grammar without touching a
warehouse, so grammar is exactly as far as it reaches.
The comparison that matters#
Run the same code four ways:
| Command | Result |
|---|---|
dbt compile (dbt Core) |
silent, no errors |
dbt run (dbt Core) |
3 Database Errors |
dbt compile --static-analysis off |
102/102 success |
dbt compile --static-analysis baseline |
102/102 success, 1 warning |
dbt compile --static-analysis strict |
4 errors — the 3 above, plus dbt0432 |
Try the dbt Core half yourself:
dbt compile --vars 'include_quarantined: true' --select tag:quarantined # silent
dbt run --vars 'include_quarantined: true' --select tag:quarantined
Completed with 3 errors, 0 partial successes, and 0 warnings:
Database Error in model dim_supplier_scorecard
invalid identifier 'SUPPLIERS.CONTRACT_START_DATE'
Done. PASS=0 WARN=0 ERROR=3 SKIP=0 NO-OP=0 TOTAL=3
Two things to take from that table.
Ahead-of-time analysis is the product. dbt Core found the column-level defects too — but only by sending broken SQL to Snowflake and reading the error back. dbt Core v2 found them from the code, with no warehouse execution.
Baseline finds almost none of them. Baseline does not download remote source schemas, so it has no column information to check against — it cannot see a single one of the five column- and type-level defects in this project. The one thing it does catch, a broken-grammar syntax error, it catches as a warning that does not even block the compile. This is exactly why the guidance is strict in development, baseline in deployment:
Reaching baseline clean is not the same as being correct
Baseline means not blocked. Strict means checked. A project that passes baseline — even with a warning logged — can still be full of the errors above. Baseline is the right setting for deployment because you do not want a job blocked on an analyzer finding — not because it is a sufficient quality gate. Five of this project's six quarantined defects are completely invisible to it.
Fix them#
Each fix below reveals a second, previously hidden defect. Re-run strict after each first fix to see it.
fct_brew_yield — potion_sku exists on both sides of the join. Qualify
every reference in the model:
Re-run, and a new error appears where the old one was:
[error] [FunctionResolutionFailed (dbt0209)]: Failed to resolve function
DATEADD: Argument type mismatch: actual: (VARCHAR, TIMESTAMP_NTZ(9), bigint);
candidates: ...
--> models/marts/fct_brew_yield.sql:27:5
dateadd('minute', brew_events.brewed_at, brew_events.brew_duration_minutes)
has its arguments transposed — Snowflake's signature is
dateadd(part, value, date), and this model passes the timestamp where the
number belongs. Swap them:
This is the capability gap the other two defects do not show: a type error is
invisible to off, invisible to baseline, and invisible to dbt Core's
compile. It only exists because the analyzer resolved types from the schema
— exactly what baseline cannot do.
dim_supplier_scorecard — the column does not exist. The analyzer tells
you what does: CONTRACTED_SINCE. Change the select list, then re-run:
[error] [UnaggregatedColumn (dbt0213)]: Un-aggregated columns in aggregation
context: SUPPLIERS.REGION must be aggregated or appear in a GROUP BY clause
--> models/marts/dim_supplier_scorecard.sql:20:5
region is in the select list but dropped from the group by — the exact
duplicate-row behavior this model was re-scoped over before it ever shipped.
Add it back.
agg_shop_channel_mix — two defects stacked, and the story behind them is
one edit interrupted. The courier_owl branch has an unclosed sum( — fix the
parenthesization first:
sum(item_totals.gross_amount_gold) as gross_revenue_gold,
count(distinct orders.order_id) as order_count
Re-run, and the defect behind it is not a column problem at all:
[error] [ProjectionFailed (dbt0301)]: Failed to align columns for UNION ALL
query: Queries must have the same number of columns. Expected 3, but got 4
--> models/marts/agg_shop_channel_mix.sql:63:1
in_store selects three columns; courier_owl selects four. Someone was
mid-edit adding order_count to the courier branch, got pulled off, and left
both an unclosed paren and two branches that no longer agree — which
select * at the union site hides from a reader completely. Add the missing
count(distinct orders.order_id) as order_count to in_store as well.
Then:
Down to the single dbt0432 finding from earlier, which
Module 3b handles.
When to opt out instead of fixing#
Sometimes a finding is an analyzer limitation, not a bug — adapter-specific SQL
the analyzer cannot fully model, for instance. static_analysis can be set per
resource, right in the model's own config():
{{
config(
materialized = 'audit_table',
static_analysis = 'off',
compliance_owner = 'guild-audit-team'
)
}}
Before you reach for this, check whether it is actually needed. Comment out
the static_analysis line in fct_regulated_potion_sales.sql and re-run
strict — the model compiles fine without it. An opt-out that nobody revisits is
a permanently unchecked model.
Quote the off
In a YAML config — dbt_project.yml or a properties file like
_marts__models.yml — +static_analysis: 'off' needs the quotes. Without
them, YAML parses bare off as the boolean False, not the string 'off'.
A Jinja config() block like the one above cannot make this mistake — a
string is always a string there. Module 5 is entirely about what happens
when the YAML form loses its quotes.
Takeaways#
- Strict finds real bugs from code alone; dbt Core needed a warehouse round trip.
- Baseline cannot see column-level or type-level problems, and treats malformed SQL as a warning rather than a block. Clean baseline ≠ correct project.
- Strict in development, baseline in deployment.
- Fix findings by default; opt out only with a justification you have tested.
Solution branch: solution/03-baseline-strict