Chapter 20 — Key Takeaways (Incremental Processing and SCD)
The page to keep open when you are about to add materialized='incremental' to something.
The one discipline
Know exactly what your model will do when it runs twice.
A
tablematerialization is stateless and therefore trivially correct. Anincrementalmodel reads its own previous output to decide what work to do, so every retry, late row, and clock skew is now a question about correctness rather than about scheduling.
Decide whether, before deciding how
| Full refresh | Incremental | Saving/yr | |
|---|---|---|---|
silver.events (14M rows/day) |
$138.24/night | $8.64 | $47,304 — 16.0× |
fct_order_item (6.48M rows) |
$1.87/night | $0.09 | $649 |
📐 Same technique, opposite answers, one project. Go incremental above roughly $5,000/year, or
when the full refresh does not fit the window. Below that, a table is cheaper in total once you
price the engineering: a watermark, a lookback, a backfill procedure, and a class of bug with no
symptom.
A third reason, unrelated to cost: the source no longer has the history. That model is incremental by necessity — and the bar for its assertions is higher, because every bug in it is permanent.
Recheck annually. The threshold moves and nobody goes back to look.
The three questions
- What is new? — the watermark predicate
- How does new combine with old? — the strategy
- What happens if it runs twice? — ← the one whose wrong answer is silent
Write all three in a comment. The next engineer cannot tell a chosen number from a defaulted one.
Idempotency: four strategies
| Use when | Breaks when | |
|---|---|---|
| Overwrite partition | writes align with partitions | window ≠ partition; DELETE+INSERT not atomic |
| Merge on key | a real unique key exists | the staged set has duplicates on it |
| Append + dedup on read | writes frequent, reads rare | reads become frequent |
| Build aside and swap | full rebuild is affordable | the table is too large to build twice |
The question that picks one: what is the smallest unit I can rewrite completely? Partition → overwrite. Row → merge. Whole table → swap. Nothing → you have an append-only log, and it needs Strategy 3 whether you planned it or not.
Swap's real value is the line in the middle: assert against the new table before it becomes visible, so a failed assertion leaves the old data live. Chapter 19's "stale beats wrong," implemented.
🔁 The test is one line, and almost nobody runs it:
dbt run --select m && dbt run --select m # then EXCEPT, both directions
Backfills
--full-refresh drops the table. It does not exist while it rebuilds; a mid-way failure leaves
you with neither copy; and a source with short retention rebuilds less than it replaced, silently.
Build aside → assert (including a row count within tolerance of the live table) → swap → keep the old copy a day. Bounded, chunked backfills beat full ones.
⚠️ A backfill still running at 03:00 competes with the nightly job. Take a lock; do not rely on remembering to pause the schedule.
Strategies by engine
append is not idempotent and is what you get by specifying nothing.
insert_overwrite replaces WHOLE partitions — nothing checks that your WHERE matches your
partition_by.
🧭 on_schema_change defaults to ignore: a new source column is silently dropped.
microbatch (dbt 1.9) generates the window filter rather than making you write it, which removes
the most common incremental bug.
Late arrivals
A watermark of
> MAX(updated_at)assumes commit order matches timestamp order. It does not. A transaction that began before your read and commits after it carries a timestamp below the watermark, and is lost — not delayed.
⚠️ A lookback without a merge is a nightly duplication engine. They are one decision.
Size it above the observed maximum, not a percentile — the rows you lose are in the tail by definition. Kestrel measured p99 = 41 min, max = 2d 4h, and set 3 days.
📐 Do not make it enormous. The lookback is what the merge scans, and a large window silently absorbs an upstream whose lag is degrading. Alert when a recovered row's lag exceeds half the window — that turns the window into a sensor.
SCD, honestly
You will build Type 1 and Type 2, and the choice is per COLUMN. email → Type 1. region →
Type 2. Both in one Type 2 dimension means every email correction creates a version.
Type 0 for attributes-at-creation (signup_country, score at underwriting) — one genuinely good
use. Types 4 and 6 usually mean the dimension is doing too much; check whether those fast-moving
attributes belong in the fact.
SCD2 mechanics
The surrogate key identifies a VERSION, not an entity. That is what keeps an Ohio order attached to Ohio forever.
-- what is true NOW AND d.is_current
-- what was true THEN AND f.ordered_at >= d.valid_from
-- AND f.ordered_at < d.valid_to
Three invariants, none checked by default:
- No overlaps → a violation fans out the point-in-time join and multiplies your revenue
- No gaps → facts in the gap land on the unknown member
- Exactly one current row per natural key
⚠️ Pick the valid_to sentinel; never NULL. x < NULL is not TRUE, so a NULL sentinel
silently drops every fact belonging to a current version — usually most of your data — and returns
a smaller result rather than an error. A sentinel fails visibly.
check_cols
⚠️
check_cols: allis the worst default in dbt. Any column change makes a version, and source tables carry heartbeats:last_login_at,session_count,last_seen_ip.
Case Study 1: dim_customer went 1,904,221 → 11,355,581 rows in five months. 9,394,233
transitions; 0.65% involved any analysed attribute. Every row was true. The dimension could no
longer answer its own question.
Enumerate them. The list is the explicit statement of what your organization considers a change worth remembering.
Snapshots are append-only and cannot be rebuilt. Test the column list on a copy.
Late-arriving dimensions
Unknown member (default, plus the volume metric) · inferred member (right for genuinely late data) · rebind.
🔁 Resuming the dimension load does not repair the facts. The watermark has passed them, nothing about them changed, and fixing the dimension changes the dimension. Run the rebind nightly, so a class of permanent damage becomes self-healing and nobody has to remember.
Testing and tuning
- Run-twice equivalence — a CI job, not a data test
- Reconciliation against a full rebuild, weekly, into a scratch schema — an incremental model drifts from its full-refresh equivalent and the drift is invisible by construction
- A volume floor per partition
- The three SCD invariants
💸 incremental_predicates is the setting most projects have never set. Without it a MERGE
scans the entire target: Kestrel's was 4 min and $0.53/night → 11 s and $0.02. An incremental
model's cost is dominated by the target scan, not the source read — the opposite of most people's
intuition, and invisible unless you read the profile.
The two case studies, compressed
A dimension grew 5.96× in five months on check_cols: all, and surfaced as a 3.4% revenue
overstatement only when version density made a date-vs-timestamp precision mismatch fan out. A
precision mismatch is harmless at low density and catastrophic at high, and the transition is
invisible. Data whose meaning changed silently in a date range is a trap with a timestamp on it.
A watermark with no lookback lost 0.037% of orders for eight months — $44,815.53, comfortably under a 0.5% reconciliation tolerance — then lost 2.1% in one night, because the loss rate scales with write concurrency. A tolerance is a budget for permanent error: state it in currency. 0.5% of $182.0M is $910,000 a year nobody will look at.