The run failed, or worse, it passed and the numbers moved. A circular ref, a test that never had rows to fail on, an incremental model quietly missing yesterday because the filter reads the model it is building.
dbt has told you something. These are the messages worth being able to read at speed, and what each one narrows the problem down to.
Green run, wrong numbers
5
The worse half. Nothing errored, every test passed, and the figure on the dashboard moved — because a filter, a join or a materialization is quietly doing something else.
Tests that pass anyway
4
A green test suite is only as good as what the tests can see. Four ways a test passes while the thing it was written to prevent is happening.
Snapshots and lost history
6
The one part of dbt that is not rebuildable. When a snapshot is wrong, the evidence is usually gone — so these are about the failure modes you have to know in advance.
01 / 20
DAG & lineageModels & ref/source
dbt fails immediately with "Found a cycle: model.shop.fct_order_flags --> model.shop.fct_orders". No SQL ran. What happened and how do you fix it?
The project — work out what dbt does with it before reading on
The source rows
raw_orders
order_id
customer_id
amount
status
updated_at
1
10
50
shipped
2026-03-01 09:00:00
2
11
30
pending
2026-03-01 10:00:00
3
10
70
shipped
2026-03-01 11:00:00
The project
models/fct_orders.sql
select order_id, amount
from {{ ref('raw_orders') }}
where order_id not in (select order_id from {{ ref('fct_order_flags') }})
models/fct_order_flags.sql
Each model refs the other. dbt has to pick a build order.
select order_id, amount > 60 as is_large
from {{ ref('fct_orders') }}
What gets run
try to rundbt run
Why they ask this
It is the clearest example of a parse-time failure, and the fix requires thinking about grain and layering rather than about SQL — which is what the interviewer wants to watch.
Say this
Two models ref each other, so there is no order dbt could build them in. Break the cycle by extracting the shared piece into a third model that both depend on.
The reasoning
dbt derives the DAG from ref calls, and a DAG cannot have a cycle. It detects this while parsing — before any connection is opened — which is why the failure is instant and no SQL appears in `target/run/`. The message names the edge it closed the loop on, which is a starting point but not always the model at fault.
The usual cause is a filter that grew. Someone needed `fct_orders` to exclude flagged orders, so they refed the flags model; the flags model was already built from `fct_orders`. Each change was reasonable on its own and the cycle appeared at the join.
The fix is almost always extraction: pull the part both need into a third model. Here, an `int_orders` model with the order rows, then `fct_order_flags` on top of it, then `fct_orders` selecting from `int_orders` and anti-joining the flags. Three nodes, no cycle, and each has a grain you can state.
Two things not to do. Do not replace one ref with a hardcoded relation name to hide the edge from the parser — the cycle still exists at runtime, you have just made dbt unable to see it, and the build order becomes luck. And do not reach for `{{ this }}`: it does not create an edge, so it compiles, but a model reading its own previous state outside an incremental materialization is a different and worse bug.
What dbt did — 1 command, in order run on dbt-core 1.12.2 / duckdb
try to rundbt runfailed
It fails at parse time, before any SQL is sent.
dbt says
Compilation Error
Found a cycle: model.shop.fct_order_flags --> model.shop.fct_orders
The answer most people give
Removing one of the refs and using the table name directly. dbt stops complaining and the dependency is still real — now with no ordering guarantee, so the model builds against yesterday's data and reports success.
They’ll ask next
Sketch the three models you would end up with, and state each one's grain.
dbt can fail with a Parsing Error, a Compilation Error, a Database Error or a Runtime Error. What does each one tell you about where to look?
Why they ask this
Triage speed is what separates someone who fixes a 3am failure in ten minutes from someone who reads the whole model. Each error type rules out most of the search space.
Say this
Parsing means dbt could not read your project — YAML or Jinja syntax, before anything is resolved. Compilation means it read it but could not render it — a bad ref, a cycle, a macro error. Database means the warehouse rejected the SQL. Runtime means it ran and something failed during execution.
The reasoning
A **Parsing Error** happens before dbt knows what your project contains: malformed YAML, a Jinja block that never closes, a config it does not recognise. It usually names a file and often a line, and nothing else in the project has been looked at yet — so the fix is always local to that file.
A **Compilation Error** means the project parsed and dbt could not turn a node into SQL. A ref to a model that does not exist, a cycle, a macro raising, an undefined variable, a contract mismatch. Nothing has been sent to the warehouse, so the problem is entirely in your Jinja and your graph — and `dbt compile --select <model>` reproduces it in isolation.
A **Database Error** is the warehouse rejecting what you sent: a syntax error in the generated SQL, a missing column, a permission problem, a type mismatch. This is the one where you open `target/run/<model>.sql` and read what was actually issued, because the model file will not show you the materialization wrapper or the resolved relation names.
A **Runtime Error** means the statement was accepted and failed while executing — a division by zero, a cast that failed on row nine million, a timeout, an out-of-memory. These are data-dependent, which is why they pass in CI on a small schema and fail in production, and why the fix is usually a defensive expression rather than a syntax change.
The answer most people give
Treating them all as "dbt broke" and rerunning. The type tells you whether the warehouse has even been contacted, which halves the search space before you have read a single line of SQL.
They’ll ask next
You get a Database Error naming a column that is definitely in your model file. Where do you look first?
CI/CD & slim CI (state:modified)Environments, profiles & targetsDAG & lineage
A model builds fine locally and fails in CI with "relation stg_orders does not exist". Both run the same commit. What is different?
Why they ask this
Environment-shaped bugs are the ones people cannot reason about from the code, and this one has three plausible causes that a good candidate will enumerate rather than guess between.
Say this
Your local schema already contains stg_orders from a previous run; CI's is empty. Either the selector did not include the upstream model, or the run is deferring to a state that does not have it.
The reasoning
The first and most common cause: your dev schema is not empty. You built the whole project last week, so `dbt run --select fct_orders` finds `stg_orders` sitting there. CI starts from an empty schema, so the same selector has nothing to select from. The fix is to select the ancestry — `--select +fct_orders` — or to build the project.
The second: slim CI without deferral configured correctly. `--select state:modified` builds only what changed, which is right, but any unchanged parent has to come from somewhere. `--defer --state <manifest>` tells dbt to resolve unbuilt refs against the production relation instead. Without it, an unmodified parent is simply missing.
The third is permissions or a schema-name difference: CI's role cannot see the schema, or `generate_schema_name` behaves differently under the CI target so the relation exists under a name nothing is looking for. Check the compiled SQL — the fully-qualified relation name in `target/compiled/` tells you immediately which schema dbt expected.
The general lesson worth stating: a dev schema accumulates state, and that state hides ordering bugs. Building from empty occasionally — a fresh schema, or `dbt build --full-refresh` in a scratch target — is how you find them before CI does.
The answer most people give
"CI is flaky, rerun it." It will fail identically, because the difference is state rather than timing. A clean schema is the entire point of CI.
They’ll ask next
How would deferral change what CI has to build, and what does it need to work?
A `channel` column is added upstream. Your incremental model is `select *`, the run succeeds, and `channel` is nowhere in the target table. Why?
The project — work out what dbt does with it before reading on
The source rows
raw_orders
order_id
customer_id
amount
updated_at
channel
1
10
50
2026-03-01 09:00:00
web
2
11
30
2026-03-01 10:00:00
app
The project
models/stg_orders.sql
The upstream model gains a `channel` column when the var is set — standing in for a source that grew one.
select
order_id,
customer_id,
amount,
updated_at
{%- if var('with_channel', false) %},
channel
{%- endif %}
from {{ ref('raw_orders') }}
models/fct_orders.sql
select *, and no on_schema_change config. What is the default?
{{ config(materialized='incremental', unique_key='order_id') }}
select *
from {{ ref('stg_orders') }}
{% if is_incremental() %}
where updated_at > (select max(updated_at) from {{ this }})
{% endif %}
What gets run
run 1dbt seed
run 1 — no channel yetdbt run
run 2 — channel appearsdbt seed
run 2dbt run --vars '{"with_channel": true}'
Why they ask this
It is a silent data loss bug in a very common setup, and the config that controls it has a default most people have never looked at.
Say this
`on_schema_change` defaults to `ignore`, so dbt inserts into the existing table using the existing columns and drops anything new. The run succeeds because nothing about it is an error.
The reasoning
On an incremental run dbt builds your select into a temporary relation and then merges or inserts into the existing target. The target's columns were fixed when it was first created, and `on_schema_change` decides what happens when the two disagree. The default is `ignore`: use the target's columns, silently discard the rest.
The four values are worth knowing exactly. `ignore` drops new columns silently. `fail` raises an error, which turns the silent loss into a run failure you will notice. `append_new_columns` adds new columns to the target, leaving historical rows null for them. `sync_all_columns` adds new ones *and* drops removed ones, which is the closest to "the target should look like the model" and the most destructive.
My default is `append_new_columns` for models where new columns are expected and history can be null, and `fail` for anything with a contract or a downstream consumer, because a schema change on those should be a human decision. `ignore` is defensible only when you are deliberately pinning the schema, and then you should not be using `select *`.
Two related points. `select *` is what makes this invisible — an explicit column list would have failed to compile when the column was missing, or done nothing when it was added, either of which is more predictable. And after switching to `append_new_columns`, historical rows still have null for the new column; if you need it populated, that is a full refresh, not a config change.
What dbt did — 4 commands, in order run on dbt-core 1.12.2 / duckdb
run 1dbt seed
run 1 — no channel yetdbt run
The warehouse now holds
fct_orders
order_id
customer_id
amount
updated_at
1
10
50
2026-03-01 09:00:00
2
11
30
2026-03-01 10:00:00
run 2 — channel appearsdbt seed
First, 1 row land in raw_orders — a new order, and the upstream model now emits `channel`
run 2dbt run --vars '{"with_channel": true}'
The run succeeds. Look for the channel column in the target.
Compiled SQLmodels/fct_orders.sql
select *
from "analytics"."main"."stg_orders"
where updated_at > (select max(updated_at) from "analytics"."main"."fct_orders")
What dbt actually ranmodels/fct_orders.sql
delete from "analytics"."main"."fct_orders"
where (
order_id) in (
select (order_id)
from "fct_orders__dbt_tmp<ts>"
);
insert into "analytics"."main"."fct_orders" ("order_id", "customer_id", "amount", "updated_at")
(
select "order_id", "customer_id", "amount", "updated_at"
from "fct_orders__dbt_tmp<ts>"
)
The warehouse now holds
fct_orders
order_id
customer_id
amount
updated_at
1
10
50
2026-03-01 09:00:00
2
11
30
2026-03-01 10:00:00
3
10
70
2026-03-01 11:00:00
The answer most people give
"dbt would have errored if the schema changed." Only with `on_schema_change: fail`, which is not the default. The default is designed not to interrupt your run, which is exactly why it loses the column quietly.
They’ll ask next
You switch to append_new_columns. What do the rows from before the change contain, and what would you do about it?
Tests (generic, singular, dbt-utils, unit tests)Macros & JinjaModels & ref/source
A model ran successfully and the table has zero rows. Every test passed. Where do you look, in what order?
Why they ask this
An open triage question with a definite good answer, and the ordering reveals whether the candidate uses the artifacts dbt gives them or reads SQL until something occurs to them.
Say this
Read `target/compiled/` first — an empty result nearly always comes from a filter that compiled to something you did not intend. Then check the upstream model has rows, then check whether a Jinja branch you expected was taken.
The reasoning
Start with the compiled SQL, because the model file cannot show you what a filter rendered to. The usual culprits are all visible there: a var that fell back to a default and filtered everything out, an `{% if %}` that was not taken so the join disappeared, a date literal that rendered as an empty string, or an incremental filter comparing against a table that already contains today's maximum.
Then check upstream. `select count(*)` on each parent relation, or `dbt ls --select +my_model` and look at whether they built in this run at all. An empty parent gives an empty child, and if the parent is a view over a source, the source may be the empty one.
Then run the compiled query yourself with the filters removed one at a time. This is the fastest way to find which predicate is doing the eliminating, and it takes a minute because you already have the exact SQL.
The reason no test caught it is worth saying: `not_null` and `unique` on an empty table both pass — there are no rows to violate them. If empty is a failure for this model, the test is a singular test asserting a minimum row count, or `dbt_utils.fewer_rows_than` against a parent. That is a genuine gap in most projects.
The answer most people give
Rerunning with `--full-refresh` first. Sometimes it fixes the symptom, which is worse than not fixing it — you have destroyed the evidence and learned nothing about why it was empty.
They’ll ask next
What test would you add so an empty result fails the build next time?
Tests (generic, singular, dbt-utils, unit tests)Models & ref/sourceDAG & lineage
Revenue in `fct_revenue` doubled after a pull request that added a join to `raw_payments`. Order 1 is worth 100 and the model reports 200. What happened?
The project — work out what dbt does with it before reading on
The source rows
raw_orders
order_id
customer_id
amount
1
10
100
2
11
40
raw_payments
payment_id
order_id
method
901
1
card
902
1
voucher
903
2
card
The project
models/fct_revenue.sql
One row per order, joined to payments. Predict the revenue for order 1.
select
o.order_id,
sum(o.amount) as revenue
from {{ ref('raw_orders') }} o
join {{ ref('raw_payments') }} p on p.order_id = o.order_id
group by 1
What gets run
seeddbt seed
build itdbt run --select fct_revenue
Why they ask this
Fan-out is the single most common way a dbt model produces a plausible wrong number, and it is invisible in review unless you are asking about grain.
Say this
The join fanned out. Order 1 has two payment rows, so joining multiplied the order row by two and the sum counted its amount twice. The join changed the grain and the aggregate did not notice.
The reasoning
The model claimed one row per order and the join to payments broke that claim. Any order with two payments — a card and a voucher, a partial refund, a retry — appears twice after the join, and `sum(o.amount)` adds the same amount once per payment row. Nothing errors, because summing duplicated rows is a perfectly valid query.
The general rule to state: a join to a table that has more than one row per join key changes the grain, and any additive measure from the *other* side is now overstated. The measure is fine; the row count is not. This is why the first question in a model review is "what is one row of this model", and why the grain belongs in the description.
The fixes, in order of preference. If you do not need payment columns, do not join — use `exists` or an anti-join. If you need one attribute, pre-aggregate payments to order grain first (`count(*) as payment_count`, `max(method)`) and join that, which keeps the join many-to-one. If you genuinely need payment grain, then this model is a payments fact and the revenue measure should not be summed at that grain at all.
The test that catches it is `unique` on `order_id` in the model. It would have failed on this pull request. It is a one-line addition and it is the single most valuable test in a dbt project, because it makes a grain claim machine-checked rather than a comment.
What dbt did — 2 commands, in order run on dbt-core 1.12.2 / duckdb
seeddbt seed
build itdbt run --select fct_revenue
Order 1 is worth 100. The model says otherwise, and no test on this model would have caught it.
The warehouse now holds
fct_revenue
order_id
revenue
1
200
2
40
The answer most people give
"Add `distinct` to the sum." `sum(distinct amount)` is a different bug — it silently drops two genuinely separate orders that happen to be for the same amount. The problem is the row count, not the values.
They’ll ask next
Write the test that would have failed on this pull request, and say where you would put it.
Your incremental model filters on `updated_at > (select max(updated_at) from {{ this }})`. Two orders land — one at 11:00 and one stamped 09:30 that was delayed. How many rows does the run add?
The project — work out what dbt does with it before reading on
The source rows
raw_orders
order_id
customer_id
amount
updated_at
1
10
50
2026-03-01 09:00:00
2
11
30
2026-03-01 10:00:00
The project
models/fct_orders.sql
The standard incremental filter. Every dbt project has one.
{{ config(materialized='incremental', unique_key='order_id') }}
select order_id, customer_id, amount, updated_at
from {{ ref('raw_orders') }}
{% if is_incremental() %}
where updated_at > (select max(updated_at) from {{ this }})
{% endif %}
What gets run
run 1dbt seed
run 1dbt run --select fct_orders
run 2dbt seed
run 2dbt run --select fct_orders
Why they ask this
The most-asked incremental question, and the one where the model that looks obviously correct is quietly losing data. Interviewers like it because the bug survives every test you would think to write.
Say this
One. The 11:00 row raises the watermark past 09:30 in the same run, so the delayed order never satisfies the filter — not now and not on any future run. It is lost permanently.
The reasoning
The filter is evaluated once against the target's current maximum, which is 10:00, so both rows pass on this run's comparison — except the maximum is recomputed from the target the *next* time, and by then it is 11:00. Anything that arrives later stamped before 11:00 is below the watermark forever. The run reports success, adds a row, and silently drops the other.
No ordinary test catches it. `unique` and `not_null` pass on the rows that are there. The absence of a row is not something a row-level assertion can see, which is why this bug lives in production for months.
What does catch it is comparing against a full refresh — rebuild the model in one pass over everything that ever arrived and compare row counts or a checksum. That is what the rebuild below does, and the gap is the missing order. Running that comparison on a schedule, or in CI on a sample, turns an invisible bug into a failing check.
The fixes, and their costs. A lookback window — `>= max(updated_at) - interval '3 days'` — reprocesses a few days each run and needs `unique_key` set so reprocessed rows update rather than duplicate; it is the standard answer and it bounds the lateness you tolerate rather than eliminating it. Filtering on an ingestion timestamp rather than a business one is better where the loader provides one, because ingestion time is monotonic by construction. And for anything where correctness matters more than cost, periodically full-refresh.
What dbt did — 4 commands, in order run on dbt-core 1.12.2 / duckdb
run 1dbt seed
run 1dbt run --select fct_orders
First run: the relation does not exist, so the guard is false and the filter is absent.
Compiled SQLmodels/fct_orders.sql
select order_id, customer_id, amount, updated_at
from "analytics"."main"."raw_orders"
The warehouse now holds
fct_orders
order_id
customer_id
amount
updated_at
1
10
50
2026-03-01 09:00:00
2
11
30
2026-03-01 10:00:00
run 2dbt seed
First, 2 rows land in raw_orders — order 4 was created at 09:30 but only reached the warehouse now
run 2dbt run --select fct_orders
Two orders arrived. Count the rows that landed.
Compiled SQLmodels/fct_orders.sql
select order_id, customer_id, amount, updated_at
from "analytics"."main"."raw_orders"
where updated_at > (select max(updated_at) from "analytics"."main"."fct_orders")
The warehouse now holds
fct_orders
order_id
customer_id
amount
updated_at
1
10
50
2026-03-01 09:00:00
2
11
30
2026-03-01 10:00:00
3
10
70
2026-03-01 11:00:00
the same thing rebuilt from scratchdbt run --select fct_orders --full-refresh
Every row that ever arrived, recomputed in one pass — and it disagrees with what the incremental runs built. That gap is the bug.
fct_orders
order_id
customer_id
amount
updated_at
1
10
50
2026-03-01 09:00:00
2
11
30
2026-03-01 10:00:00
3
10
70
2026-03-01 11:00:00
4
12
20
2026-03-01 09:30:00
The answer most people give
"Both, because both are newer than the last run." The filter compares against the maximum in the target, not against when the last run happened. The 09:30 row is older than rows already loaded.
They’ll ask next
You add a three-day lookback. What else must be set, and what happens without it?
A model has an `{% if is_incremental() %}` filter and is materialized as a table. The numbers are always right. What is wrong?
The project — work out what dbt does with it before reading on
The source rows
raw_orders
order_id
customer_id
amount
status
updated_at
1
10
50
shipped
2026-03-01 09:00:00
2
11
30
pending
2026-03-01 10:00:00
3
10
70
shipped
2026-03-01 11:00:00
The project
models/fct_orders.sql
Someone changed the materialization and left the guard behind. No error follows.
{{ config(materialized='table') }}
select order_id, customer_id, amount, updated_at
from {{ ref('raw_orders') }}
{% if is_incremental() %}
where updated_at > (select max(updated_at) from {{ this }})
{% endif %}
What gets run
seeddbt seed
run 1dbt run --select fct_orders
run 2dbt run --select fct_orders
Why they ask this
It tests whether the candidate reads config and logic together. The bug produces correct output, so only cost reveals it — which is exactly the class of problem senior engineers are expected to find.
Say this
`is_incremental()` is false on a non-incremental model, so the filter never applies and the model rebuilds in full on every run. Right answer, every night, at full cost — and someone thinks it is incremental.
The reasoning
`is_incremental()` returns true only when the materialization is `incremental`, the relation exists, and the run is not a full refresh. Change the materialization to `table` and the first condition fails permanently, so the guarded block is never emitted. The compiled SQL is a plain unfiltered select and the DDL is a drop-and-recreate.
It usually arrives one of two ways. Someone hit an incremental correctness problem, switched to `table` as a temporary fix, and left the guard in place. Or someone copied a model as a template and changed the materialization without reading the body.
Why it survives: the output is correct. Every test passes, the reconciliation against a full refresh passes trivially, and the only symptom is the run time and the bill. On a large model that is a genuinely expensive mistake, and on a project with twenty of them it is the whole warehouse budget.
How to find them across a project: `dbt ls --select config.materialized:incremental` gives you the real list, and comparing it against a grep for `is_incremental` finds both directions of the mismatch — guards on non-incremental models, and incremental models with no guard at all, which is the opposite and worse bug because it rebuilds the full select into the target every run and duplicates or overwrites depending on the strategy.
What dbt did — 3 commands, in order run on dbt-core 1.12.2 / duckdb
seeddbt seed
run 1dbt run --select fct_orders
The warehouse now holds
fct_orders
order_id
customer_id
amount
updated_at
1
10
50
2026-03-01 09:00:00
2
11
30
2026-03-01 10:00:00
3
10
70
2026-03-01 11:00:00
run 2dbt run --select fct_orders
Right answer, every time, for the wrong reason — and at full cost.
Compiled SQLmodels/fct_orders.sql
select order_id, customer_id, amount, updated_at
from "analytics"."main"."raw_orders"
What dbt actually ranmodels/fct_orders.sql
create table
"analytics"."main"."fct_orders__dbt_tmp"
as (
select order_id, customer_id, amount, updated_at
from "analytics"."main"."raw_orders"
);
The warehouse now holds
fct_orders
order_id
customer_id
amount
updated_at
1
10
50
2026-03-01 09:00:00
2
11
30
2026-03-01 10:00:00
3
10
70
2026-03-01 11:00:00
The answer most people give
"Nothing — it works." It works by recomputing everything every night. The model file says incremental to every reader, and the config says otherwise, so the next person to touch it will make a decision on a false premise.
They’ll ask next
How would you find every model in a 400-model project with this mismatch?
A mart returns different numbers in production than in your dev environment, on the same commit and the same source data. What kinds of things cause that?
Why they ask this
It forces the candidate to enumerate everything that can differ between environments, which is a good proxy for how much production dbt they have actually run.
Say this
Something environment-dependent got into the logic: a `target.name` branch that does more than limit rows, a var with a different value, a source pointing at a different database, or — most often — different accumulated state in an incremental model or snapshot.
The reasoning
Start with the compiled SQL from both environments and diff them. If they differ, the cause is in the compilation: a `{% if target.name %}` branch, a var set differently in the production job, an `env_var` with a different value, or `generate_schema_name` resolving a source or ref to a different relation. This is the fastest check and it is decisive.
If the compiled SQL is identical, the difference is state. An incremental model in production has months of accumulated rows built by whatever the logic was at the time; your dev copy was built once, from scratch, by today's logic. Those are not the same table, and if the logic has changed since, production contains a mixture. The same applies to snapshots, which record what your production runs happened to observe.
The other state difference is upstream: a source that is genuinely different — a dev database populated by a sample, or a staging copy of the vendor feed — or an upstream model that was full-refreshed in dev and is incremental in production.
The prevention is the interesting half. Keep environment branches to row limits only. Deferral (`--defer --state`) lets dev read production relations for unmodified models, which removes most of the state divergence. And when an incremental model's logic changes, full-refresh it in production, because otherwise the mixture is permanent.
The answer most people give
"Production has more data." Usually true and rarely the explanation for different *numbers* on the same inputs. The two real causes are compiled SQL that differs and accumulated state that differs.
They’ll ask next
Your dev copy of an incremental model was built yesterday from scratch. Why is that not the same table as production?
Models & ref/sourceTests (generic, singular, dbt-utils, unit tests)
A daily revenue mart disagrees with the finance team's number by roughly one day's worth at month end. Every test passes. Where do you look?
Why they ask this
Timezone bugs are endemic in warehouses and produce exactly this signature. It also tests whether the candidate asks what the business definition of a day is before touching SQL.
Say this
Timezone handling on the date truncation. Events stored in UTC and bucketed by UTC day will disagree with a finance team that means local days, and the disagreement concentrates at the boundary — which at month end is a whole day.
The reasoning
The mechanism: `date_trunc('day', event_at)` on a UTC timestamp puts an 11pm New York event into the next UTC day. Across a month those misplacements mostly cancel between adjacent days, so daily numbers look approximately right, but at a month boundary they do not cancel — the first and last day of the month are wrong in opposite directions and the month total is off.
Before writing SQL, settle the definition: whose day? Finance almost always means a business-local day, and "local" may mean the company's headquarters, the customer's timezone, or the store's. Those are three different models and only one of them is what the report means.
Then implement it in one place. Convert to the business timezone in the staging layer, store both the UTC instant and the business date as separate columns, and have every mart bucket on the business date column. The mistake to avoid is converting in each mart, because then the conversion exists in eight places with three opinions and daylight saving will eventually catch one of them.
Two adjacent causes with the same signature, worth ruling out: a filter with `<` where it should be `<=` on the last day, and an incremental model whose watermark is in a different timezone from the data it compares against — which drops or reprocesses exactly the boundary rows.
The answer most people give
"Add a timezone conversion to the mart." It fixes this report and leaves the other seven, each of which will be fixed differently later. The conversion belongs once, in staging, as a column.
They’ll ask next
Where would you store the business date, and why not compute it in each mart?
Tests (generic, singular, dbt-utils, unit tests)Models & ref/source
`dim_customers` has four rows, three of which have a null `customer_key`. The `unique` test on that column passes. Why, and what should you have written?
The project — work out what dbt does with it before reading on
The source rows
raw_customers
customer_email
tier
a@example.com
pro
NULL
free
NULL
free
NULL
pro
The project
models/dim_customers.sql
select
customer_email as customer_key,
tier
from {{ ref('raw_customers') }}
It is the most common false sense of security in a dbt project — a key column with a unique test that does not actually guarantee a key.
Say this
dbt's unique test groups by the column and looks for groups with more than one row, and nulls are excluded by the underlying comparison. Three nulls are not duplicates of each other, so it passes. You need `not_null` alongside it.
The reasoning
The generated SQL groups the column and filters to groups with a count above one — and null handling in SQL means nulls do not form a group that counts as duplicated in the way you expect. The test's job is duplicate detection and it does that correctly; it was never a key check.
`unique` and `not_null` together are what make a key claim. Neither alone is sufficient: `not_null` allows duplicates, `unique` allows a column that is null on every row. This is why every generated dbt project template puts both on the primary key column, and why a review comment that says "add not_null" on a keyed column is always right.
The stronger version for a composite key is a `dbt_utils.unique_combination_of_columns` test on the column list, since testing each column individually says nothing about the pair. And for the null case specifically, the interesting question is usually upstream: why is the key null? A surrogate key built by concatenation goes null when any component is null, so a null key often means the key macro is the bug rather than the data.
Two related tests that hide the same way. `accepted_values` also passes on nulls unless you add `not_null`, and `relationships` passes for rows whose foreign key is null — which is often legitimate but means the test cannot tell you whether ninety percent of your fact rows failed to match.
What dbt did — 2 commands, in order run on dbt-core 1.12.2 / duckdb
load the sourcedbt seed
build and testdbt build --select dim_customers
Three of the four keys are null. The unique test is green.
Test results
successdim_customers
passunique_dim_customers_customer_key
The warehouse now holds
dim_customers
customer_key
tier
NULL
free
NULL
free
a@example.com
pro
NULL
pro
The answer most people give
"Three nulls are three duplicates, so the test is broken." The test is doing what it says. The misunderstanding is treating `unique` as a primary key assertion when it is a duplicate check.
They’ll ask next
Your surrogate key is null on ten percent of rows. What is the most likely cause?
Tests (generic, singular, dbt-utils, unit tests)Source freshness
A model built empty because an upstream filter went wrong, and all fourteen of its tests passed. How would you have caught it?
Why they ask this
It exposes the structural limit of row-level testing, and the fix requires a different kind of test that most projects do not have.
Say this
Every dbt test asserts something about the rows present, so zero rows satisfies all of them vacuously. You need an assertion about the row count itself — a singular test, `dbt_utils.fewer_rows_than`, or source freshness upstream.
The reasoning
`not_null` selects rows where the column is null and finds none. `unique` finds no duplicated groups. `accepted_values` finds no unexpected values. `relationships` finds no orphans. All of them return zero rows and all of them pass, correctly and uselessly.
The direct fix is a test on volume. A singular test in `tests/` — `select 1 where (select count(*) from {{ ref('fct_orders') }}) = 0` — is four lines and turns empty into a failure. `dbt_utils.fewer_rows_than` compares a model against its parent, which catches the more general case where the model is not empty but has lost most of its rows.
The better fix is usually upstream. An empty model almost always comes from an empty or stale source, and `dbt source freshness` running before the build catches it earlier and localises it better. A count that collapses without the source being stale is a logic bug, and that is where a row-count test on the model itself earns its place.
The related trap worth mentioning: a `where` filter on a test config also shrinks what the test can see. `where: "created_at >= current_date - 7"` is a reasonable way to keep a test cheap, and it also means the test passes on a table where everything older than a week is broken. Cheap tests and thorough tests are different tests.
The answer most people give
"Add more column tests." Every column test passes on an empty table by construction. The assertion has to be about the number of rows, and no built-in generic test makes one.
They’ll ask next
Write the singular test in words, and say why you would rather catch this at the source.
Tests (generic, singular, dbt-utils, unit tests)Incremental strategies (merge / insert_overwrite / append) & unique_keyCI/CD & slim CI (state:modified)
You add a unit test to an incremental model and dbt refuses to parse the project: "Boolean override for 'is_incremental' must be provided". What is dbt asking for and why?
The project — work out what dbt does with it before reading on
The source rows
raw_orders
order_id
customer_id
amount
updated_at
1
10
50
2026-03-01 09:00:00
The project
models/fct_orders.sql
{{ config(materialized='incremental', unique_key='order_id') }}
select order_id, customer_id, amount, updated_at
from {{ ref('raw_orders') }}
{% if is_incremental() %}
where updated_at > (select max(updated_at) from {{ this }})
{% endif %}
models/schema.yml
A unit test with given and expect. It never gets as far as running.
Unit tests are new enough that many candidates have not used them, and this specific error is the first thing anyone hits — so it is a good check on whether the experience is real.
Say this
An incremental model compiles to two different queries depending on `is_incremental()`, so dbt cannot know which one you meant to test. You declare it under `overrides: {macros: {is_incremental: true}}`.
The reasoning
A unit test runs the model's SQL against rows you supply and compares the result to rows you expect. For that to be meaningful, there has to be one query. An incremental model has two — the first-run branch and the incremental branch — and they legitimately return different things from the same input. dbt refuses to guess.
The declaration goes in the unit test: an `overrides` block setting `macros: {is_incremental: false}` to test the full-refresh path, or `true` to test the incremental path. Testing both is two unit tests, which is usually what you want, because the branch that only runs in production is the one nobody has exercised.
That is the real value here. CI builds into an empty schema, so it always takes the first-run branch — the incremental filter is exactly the logic your pipeline never tests. A unit test with `is_incremental: true` is the only cheap way to assert anything about it, and it does so without a warehouse round trip.
The wider point about unit tests: they test *logic* against fixed input, where data tests assert things about real data. A unit test is where you pin down a case statement's edge cases, a window function's tie-break, or a date-bucketing rule — the things that are hard to produce in real data and easy to get wrong.
What dbt did — 1 command, in order run on dbt-core 1.12.2 / duckdb
try to testdbt test --select fct_ordersfailed
A parse error, not a test failure. dbt is telling you something specific.
dbt says
Parsing Error
Boolean override for 'is_incremental' must be provided for unit test 'large_orders_only' in model 'fct_orders'
The answer most people give
"Unit tests do not work on incremental models." They do — dbt is asking you to say which branch you mean, because the model has two and testing the wrong one proves nothing.
They’ll ask next
Which branch does your CI normally exercise, and what does that mean for incremental bugs?
Tests (generic, singular, dbt-utils, unit tests)CI/CD & slim CI (state:modified)
A test has been failing as a warning for four months and nobody has looked at it. Is severity `warn` useful, and how would you configure it properly?
Why they ask this
A judgment question about operating a test suite rather than writing one. Everyone has this problem and few people have a policy for it.
Say this
`warn` is useful only with a threshold and an owner. Configure `error_if` and `warn_if` so the test escalates when the problem gets worse, and treat a permanent warning as either a bug to fix or a test to delete.
The reasoning
`severity: warn` makes a failing test report without failing the run, which is right for something that is genuinely tolerable — a handful of orphaned rows from a known vendor issue. It becomes noise the moment nobody acts on it, and a test suite with standing warnings trains everyone to ignore the output, which costs you the tests that matter.
The configuration that makes it work is thresholds. `error_if: ">100"` and `warn_if: ">0"` means the test warns while the problem is small and fails the run when it grows. That encodes the actual policy — a few is survivable, a lot is not — instead of a binary that does not match reality.
`store_failures: true` is the other half. It writes the failing rows to a table so you can look at what is actually failing rather than at a count, and it makes a standing warning investigable months later. Combined with a threshold, you get a warning you can triage instead of a warning you dismiss.
The policy I would argue for: every warn-severity test has a named owner and a review date. If neither exists, it is either an error or it should be deleted. A test that has warned for four months has already told you everything it is going to.
The answer most people give
"Set everything to error so nothing gets ignored." That makes the run fail on tolerable conditions and trains people to rerun with tests disabled, which is worse than a warning nobody reads.
They’ll ask next
Where would you look to see which rows have been failing for four months?
day 2 — customer 10 upgradesdbt run --select src_customers --vars '{"as_of": 2}'
day 2dbt snapshot
Why they ask this
The baseline snapshot question. Interviewers want the mechanism — close the old row, insert the new one — and the four dbt-managed columns, because those are what downstream queries join on.
Say this
It closes the existing row by setting `dbt_valid_to`, and inserts a new row for the current values with `dbt_valid_to` null. The managed columns are `dbt_scd_id`, `dbt_updated_at`, `dbt_valid_from` and `dbt_valid_to`.
The reasoning
On each run dbt compares the source against the snapshot's currently-open rows. Unchanged records are left alone. Changed records get two operations: an update setting the open row's `dbt_valid_to` to the new version's timestamp, and an insert of the new version with `dbt_valid_from` set to that same timestamp and `dbt_valid_to` null. The result is a Type 2 dimension — one row per version of each entity, with a validity interval.
The four columns: `dbt_scd_id` is a hash that uniquely identifies the version row and is the snapshot's primary key. `dbt_updated_at` is the value the strategy used to decide something changed. `dbt_valid_from` and `dbt_valid_to` are the interval, with null meaning current.
How you query it is the follow-up. `where dbt_valid_to is null` gives you the current state, which is what a dimension usually wants. A temporal join — `on f.event_at >= d.dbt_valid_from and (f.event_at < d.dbt_valid_to or d.dbt_valid_to is null)` — gives you what the customer's tier was at the time of the event, which is the entire reason to snapshot.
The boundary detail worth getting right: intervals are half-open, so the closing row's `dbt_valid_to` equals the next row's `dbt_valid_from`. Using `<=` on both ends in a temporal join double-counts events landing exactly on the boundary — which is rare, non-obvious and produces duplicated facts.
What dbt did — 5 commands, in order run on dbt-core 1.12.2 / duckdb
seeddbt seed
day 1dbt run --select src_customers
day 1dbt snapshot
Two customers, both current.
The warehouse now holds
snap_customers
customer_id
tier
updated_at
dbt_scd_id
dbt_updated_at
dbt_valid_from
dbt_valid_to
10
free
2026-03-01 08:00:00
25081f289077cd94abb20f94c7cb8a48
2026-03-01 08:00:00
2026-03-01 08:00:00
NULL
11
free
2026-03-01 08:00:00
5d3d27c34b67dd754afeb16a7c952f34
2026-03-01 08:00:00
2026-03-01 08:00:00
NULL
day 2 — customer 10 upgradesdbt run --select src_customers --vars '{"as_of": 2}'
day 2dbt snapshot
Count the rows. Which one has a dbt_valid_to?
The warehouse now holds
snap_customers
customer_id
tier
updated_at
dbt_scd_id
dbt_updated_at
dbt_valid_from
dbt_valid_to
10
free
2026-03-01 08:00:00
25081f289077cd94abb20f94c7cb8a48
2026-03-01 08:00:00
2026-03-01 08:00:00
2026-03-02 09:00:00
10
pro
2026-03-02 09:00:00
4449556c8ae0f2e292731ac3442eca98
2026-03-02 09:00:00
2026-03-02 09:00:00
NULL
11
free
2026-03-01 08:00:00
5d3d27c34b67dd754afeb16a7c952f34
2026-03-01 08:00:00
2026-03-01 08:00:00
NULL
The answer most people give
"It updates the row with the new tier." That is Type 1 and it is what snapshots exist to avoid. The old value is preserved with an end date — otherwise there is no history to have snapshotted.
They’ll ask next
Write the join condition for "what tier was this customer on when the order was placed".
A customer's tier changed from free to pro, the snapshot ran, and the snapshot table is unchanged. The timestamp strategy is configured correctly. What happened?
The project — work out what dbt does with it before reading on
The source rows
customer_versions
as_of
customer_id
tier
updated_at
1
10
free
2026-03-01 08:00:00
2
10
pro
2026-03-01 08:00:00
The project
models/src_customers.sql
select
customer_id,
tier,
updated_at
from {{ ref('customer_versions') }}
where as_of = {{ var('as_of', 1) }}
snapshots/snap_customers.sql
The timestamp strategy, against a source whose updated_at is not maintained.
day 2 — tier changes, updated_at does notdbt run --select src_customers --vars '{"as_of": 2}'
day 2dbt snapshot
Why they ask this
It is the failure mode of the default strategy and it depends on a property of the source that nobody checks: whether `updated_at` is actually maintained.
Say this
The source's `updated_at` did not move when the tier did. The timestamp strategy only looks at that one column, so as far as dbt is concerned nothing changed. Switch to the check strategy on the columns you care about.
The reasoning
The timestamp strategy is a shortcut: it trusts the source to tell it when a row changed, and compares only `updated_at`. That is cheap and it is correct exactly as often as the source's timestamp is trustworthy. Plenty of systems update a row through a path that does not touch it — a bulk correction, a direct database edit, a replication tool that preserves the original value, a column maintained by application code that one code path forgot.
The check strategy compares the actual column values instead. `strategy='check'` with `check_cols=['tier', 'status']` hashes those columns and records a new version whenever the hash moves, regardless of any timestamp. `check_cols='all'` watches every column, which is the safest and the most expensive — and which will also record a version every time an irrelevant column changes.
The trade-off to state: timestamp is cheaper — one column comparison, no hashing, and it uses the source's own notion of when the change happened, so `dbt_valid_from` is meaningful. Check is more reliable and stamps `dbt_valid_from` with the run time, so your history's resolution is your run schedule and the timestamps tell you when you *noticed*, not when it happened.
How to decide: audit the source. If `updated_at` is maintained by a database trigger or by a CDC tool, trust it. If it is set by application code, check it against the columns you care about for a week before relying on it. And when you switch an existing snapshot from timestamp to check, be aware the two produce different `dbt_valid_from` semantics, so your history will have a seam.
What dbt did — 5 commands, in order run on dbt-core 1.12.2 / duckdb
day 1dbt seed
day 1dbt run --select src_customers
day 1dbt snapshot
The warehouse now holds
snap_customers
customer_id
tier
updated_at
is_current
10
free
2026-03-01 08:00:00
true
day 2 — tier changes, updated_at does notdbt run --select src_customers --vars '{"as_of": 2}'
day 2dbt snapshot
The warehouse now holds
snap_customers
customer_id
tier
updated_at
is_current
10
free
2026-03-01 08:00:00
true
The answer most people give
"The snapshot did not run." It ran and correctly decided nothing had changed, by the only measure it was given. The bug is in what it was told to watch.
They’ll ask next
You switch to check_cols. What happens to dbt_valid_from, and what does that cost you?
Snapshots & SCD2Tests (generic, singular, dbt-utils, unit tests)
The same source, the same unchanged `updated_at`, but the snapshot is configured with `strategy='check'` and `check_cols=['tier']`. What does the table look like after the run?
The project — work out what dbt does with it before reading on
The source rows
customer_versions
as_of
customer_id
tier
updated_at
1
10
free
2026-03-01 08:00:00
2
10
pro
2026-03-01 08:00:00
The project
models/src_customers.sql
select
customer_id,
tier,
updated_at
from {{ ref('customer_versions') }}
where as_of = {{ var('as_of', 1) }}
snapshots/snap_customers.sql
The same source, with the check strategy watching the tier column itself.
day 2 — tier changes, updated_at does notdbt run --select src_customers --vars '{"as_of": 2}'
day 2dbt snapshot
Why they ask this
The paired half of the previous question. Seeing both outcomes on identical input is what makes the trade-off concrete rather than a list of pros and cons.
Say this
Two rows: the free version closed, and a new open row for pro. The check strategy compared the tier column itself, so it saw the change that the timestamp strategy missed.
The reasoning
dbt hashes the `check_cols` for each source row and compares against the hash stored on the open snapshot row. A different hash means a new version, regardless of any timestamp. That is why this run produces the two rows the timestamp strategy did not.
Note what the validity columns now mean. `dbt_valid_from` on the new row is the moment the snapshot *ran*, not the moment the tier changed — dbt has no way to know the latter, because the source did not record it. Your history's granularity is therefore your run frequency, and a change that happens and reverts between two runs is invisible.
Choosing `check_cols`: naming the columns is precise and means an unrelated column changing does not create a spurious version, but a column you forgot to list is a change you will never see. `'all'` is safe against omission and will record a version whenever any column moves, including ones you do not care about — which on a wide table means a lot of rows recording nothing interesting.
The middle path most teams land on: `check_cols` listing the business attributes the dimension is actually about, plus a `unique` test on `dbt_scd_id` and a check that no entity has two open rows. That last one is the assertion that actually protects you, because a snapshot with two rows where `dbt_valid_to is null` will silently duplicate every fact you join to it.
What dbt did — 5 commands, in order run on dbt-core 1.12.2 / duckdb
day 1dbt seed
day 1dbt run --select src_customers
day 1dbt snapshot
The warehouse now holds
snap_customers
customer_id
tier
updated_at
is_current
10
free
2026-03-01 08:00:00
true
day 2 — tier changes, updated_at does notdbt run --select src_customers --vars '{"as_of": 2}'
day 2dbt snapshot
The warehouse now holds
snap_customers
customer_id
tier
updated_at
is_current
10
free
2026-03-01 08:00:00
false
10
pro
2026-03-01 08:00:00
true
The answer most people give
"Nothing, because updated_at did not change." That is the timestamp strategy's behaviour. Check ignores timestamps entirely and compares the column values.
They’ll ask next
What test would you write to catch an entity with two open rows?
day 3 — customer 11 is deleted from the sourcedbt run --select src_customers --vars '{"as_of": 3}'
day 3dbt snapshot
Why they ask this
Hard deletes are the snapshot behaviour people are most often unaware of, and the consequence — a dimension that only ever grows and reports deleted entities as current — is a real reporting bug.
Say this
Nothing happens to it. dbt only compares rows that are present in the source, so a deleted row stays open with `dbt_valid_to` null forever and looks current. Set `hard_deletes='invalidate'` to close it.
The reasoning
The comparison is one-directional: dbt looks at each source row and asks whether it has changed. A row absent from the source is never examined, so its open snapshot row is never touched. By default your snapshot is an accumulating record of everything that ever existed, all of it apparently current.
The reporting consequence is concrete. `where dbt_valid_to is null` — the standard current-state query, and the basis of most dimensions built on snapshots — returns deleted customers alongside live ones. Counts are inflated, and a temporal join attributes events to entities that no longer exist.
`hard_deletes='invalidate'` makes dbt close rows that have disappeared, setting `dbt_valid_to` to the run timestamp. `hard_deletes='new_record'` instead inserts a tombstone row with a `dbt_is_deleted` flag, which is more explicit and lets a consumer distinguish "deleted" from "changed to something else". Older projects will have the equivalent `invalidate_hard_deletes=true`.
The caution that matters: with invalidation on, a source that is *temporarily* incomplete closes rows that were never deleted. A partial load, a failed extract, a filter change upstream — any of these will look like a mass deletion, and the next run reopens them as new versions, leaving fake history. So invalidation belongs on sources where you trust completeness, and freshness or volume checks belong in front of it.
What dbt did — 5 commands, in order run on dbt-core 1.12.2 / duckdb
day 1dbt seed
day 1dbt run --select src_customers
day 1dbt snapshot
The warehouse now holds
snap_customers
customer_id
tier
updated_at
dbt_scd_id
dbt_updated_at
dbt_valid_from
dbt_valid_to
10
free
2026-03-01 08:00:00
25081f289077cd94abb20f94c7cb8a48
2026-03-01 08:00:00
2026-03-01 08:00:00
NULL
11
free
2026-03-01 08:00:00
5d3d27c34b67dd754afeb16a7c952f34
2026-03-01 08:00:00
2026-03-01 08:00:00
NULL
day 3 — customer 11 is deleted from the sourcedbt run --select src_customers --vars '{"as_of": 3}'
day 3dbt snapshot
Customer 11 no longer exists upstream. Look at its dbt_valid_to.
The warehouse now holds
snap_customers
customer_id
tier
updated_at
dbt_scd_id
dbt_updated_at
dbt_valid_from
dbt_valid_to
10
free
2026-03-01 08:00:00
25081f289077cd94abb20f94c7cb8a48
2026-03-01 08:00:00
2026-03-01 08:00:00
2026-03-02 09:00:00
10
pro
2026-03-02 09:00:00
4449556c8ae0f2e292731ac3442eca98
2026-03-02 09:00:00
2026-03-02 09:00:00
NULL
11
free
2026-03-01 08:00:00
5d3d27c34b67dd754afeb16a7c952f34
2026-03-01 08:00:00
2026-03-01 08:00:00
NULL
The answer most people give
"dbt deletes the row from the snapshot." A snapshot never deletes — that would destroy the history it exists to keep. The question is only whether the row is marked as no longer current.
They’ll ask next
Your extract fails and delivers half the customers. What does invalidate do, and how do you protect against it?
Snapshots & SCD2CI/CD & slim CI (state:modified)Environments, profiles & targets
Someone ran `dbt build --full-refresh` in production and it included your snapshots. What have you lost, and can you recover it?
Why they ask this
It is the one genuinely unrecoverable dbt accident, so an interviewer is checking whether you understand the asymmetry between models and snapshots — and whether you would have prevented it.
Say this
Every version except the current one, and no, you cannot recover it from the source — the source only holds the present. You recover from a warehouse backup or time-travel if you have one, and otherwise the history is gone.
The reasoning
A full refresh drops and rebuilds. For a model that is harmless: it is a pure function of its inputs and comes back identical. A snapshot is not — its rows record what the source looked like at the times you happened to run it, and those past states exist nowhere else. Rebuilding gives you one row per entity, valid from now, and every prior version is deleted.
Recovery options are all warehouse-level. Snowflake Time Travel, BigQuery table snapshots, Databricks Delta time travel, or an ordinary backup — if you notice inside the retention window. Outside it, the history is unrecoverable, which is a genuinely different category of incident from anything else dbt can do to you.
Prevention is the part worth being specific about, because "be careful" is not a control. Exclude snapshots from anything that can full-refresh: `--exclude resource_type:snapshot` in every job that passes the flag. Run snapshots as their own job with `dbt snapshot`, so no `dbt build --full-refresh` can reach them. Restrict who can run a full refresh in production at all. And a pre-hook that raises when `flags.FULL_REFRESH` is true on a snapshot is a five-line macro that makes the accident impossible.
The related habit: back the snapshot up before any change to its config. Switching strategies, changing `check_cols` or altering the unique key all change how future rows are produced and can invalidate the comparison against existing ones — and once you have run it, undoing that is the same problem as this one.
The answer most people give
"Rerun dbt snapshot and it will rebuild the history." It will build one row per current entity. The source has no memory of what a customer's tier was in March, which is precisely why the snapshot existed.
They’ll ask next
Write the guard you would put in place so this cannot happen again.
day 3 — customer 11 is deleteddbt run --select src_customers --vars '{"as_of": 3}'
day 3dbt snapshot
Why they ask this
The natural follow-up, and the one that separates a candidate who knows the config from one who has operated it. Turning on invalidation trades a known reporting bug for a data-loss-shaped one.
Say this
The deleted customer's row is now closed, with `dbt_valid_to` set to the run timestamp. The risk is that an incomplete extract looks exactly like a mass deletion, so a partial load closes rows that were never deleted.
The reasoning
With invalidation on, dbt finds open snapshot rows whose key is absent from the source and closes them. The current-state query is now honest: `where dbt_valid_to is null` returns live entities only, and counts stop drifting upward forever.
The risk is that dbt cannot tell a deletion from an absence. A failed extract, a partial load, a filter someone added upstream, a source model that errored and left yesterday's smaller table behind — all of them present as "these rows are gone". The snapshot closes them, and the next successful run reopens them as brand-new versions with a fresh `dbt_valid_from`. You end up with fabricated history: a gap that never happened, and a change event for every affected entity.
That fabricated history is worse than the original problem because it is not obviously wrong. Nobody notices a dimension where five thousand customers churned and un-churned overnight until a retention chart looks strange months later, and by then the rows are indistinguishable from real ones.
So invalidation needs a gate in front of it. Source freshness before the snapshot, so a stale feed stops the pipeline. A volume check — today's source row count against a recent average — so a collapse fails rather than propagates. And running snapshots in their own job that only starts if the upstream load reported success. `hard_deletes='new_record'` is the softer alternative: it inserts an explicit tombstone rather than closing the row, which at least makes a spurious deletion visible as a row you can find and delete.
What dbt did — 5 commands, in order run on dbt-core 1.12.2 / duckdb
day 1dbt seed
day 1dbt run --select src_customers
day 1dbt snapshot
The warehouse now holds
snap_customers
customer_id
tier
dbt_valid_from
is_current
10
free
2026-03-01 08:00:00
true
11
free
2026-03-01 08:00:00
true
day 3 — customer 11 is deleteddbt run --select src_customers --vars '{"as_of": 3}'
day 3dbt snapshot
Now the deleted customer's row is closed.
The warehouse now holds
snap_customers
customer_id
tier
dbt_valid_from
is_current
10
free
2026-03-01 08:00:00
false
10
pro
2026-03-02 09:00:00
true
11
free
2026-03-01 08:00:00
false
The answer most people give
"None, it is strictly better." It is better for correct sources and actively dangerous for unreliable ones. The default exists because dbt cannot tell absence from deletion, and neither can you without a check in front of it.
They’ll ask next
Sketch the volume check you would run before the snapshot, and where it would sit in the job.