learn-analytics-engineering-with-phoebe / Builder session 6 of 10
Learn Analytics Engineering with Phoebe · Builder track · Session 6 of 10

Dimensions, facts, snapshots

Twelve mart models: six dimensions, three facts and three aggregates. The dimensional design is not this course's - it comes from learn-data-modeling, and it was already correct. What changes when you rebuild it in dbt is the mechanics: keys that survive being rebuilt from scratch, a hash macro written out so there is no magic in it, LEFT JOINs that refuse to drop a fact row, and then the part a hand-written warehouse genuinely could not do - a snapshot that maintains type-2 history for you instead of just having a place to put it.

🟠 Builder track Analytics engineers · analysts · DE Runs in your browser · real DuckDB 45 min
0-3 · Welcome 3-17 · Keys, dimensions and the joins that protect facts 17-41 · Build-along, then snapshots as the real payoff 41-45 · Q&A
Part 0

What is actually new here

Be clear about the split before you start, because it saves you from thinking dbt taught you dimensional modeling. Grain, conformed dimensions, additivity, degenerate dimensions, SCD types: all of that belongs to learn-data-modeling, and this session assumes you already have it. What is new is the engineering underneath a model that gets rebuilt from scratch on every run.

That single fact drives most of today's session. A warehouse that is rebuilt cannot use a database sequence for its keys. It cannot rely on an UPDATE to close a dimension row, because the row will be dropped and recreated. It cannot let an inner join quietly reduce a fact table, because nobody will notice a fact table that came back smaller. dbt has an answer for each, and for the third one - history - the answer is a first-class object called a snapshot, which is where this session ends up.

Live - presented in session Self-study - read after class ▶ Live SQL - real DuckDB, editable Sources covered
★ What you walk out with today A surrogate-key macro you could write from memory and defend in review, the coalesce-to-unknown discipline measured at a 0.0% unknown rate, an inner join demonstrated silently eating fact rows, and a working understanding of strategy: check snapshots - including the honest boundary between designing SCD2 and maintaining it.
Part 1 · covers dbt documentation on macros, plus Kimball & Ross patterns being implemented

Keys that survive a rebuild 7 min live

Every dimension in this project has a surrogate key, and every one of them is a 32-character hash of the business key. Not a sequence, not an identity column, not row_number(). Understanding why is the fastest route to understanding what is different about a warehouse built by a tool that rebuilds everything.

A database sequence: wrong here Monday build M07 gets 7, M12 gets 12 Tuesday build M07 gets 3, M12 gets 5 Yesterday's fact rows now point at the wrong merchant. dbt drops and recreates every mart, so the sequence restarts and hands out different numbers every run. A hash of the business key: right Monday build M07 to the same 32-char hash Tuesday build M07 to the same 32-char hash Deterministic: no state to keep, nothing to coordinate. This is what dbt_utils.generate_surrogate_key does. macros/surrogate_key.sql writes it out, so no magic. The macro, in full - eleven lines of Jinja and one function call md5(concat_ws('|', coalesce(cast(field as varchar), '_null_'), ... )) concat_ws with a separator, so 'AB' plus 'C' can never collide with 'A' plus 'BC'. And coalesce, so a null business key still produces a key rather than a null that joins to nothing.
🔍 Click to zoom - why the key is a hash, and what the hash has to get right

The six dimensions, and what each one is doing. Note that two of them are generated rather than sourced, and one of them is only the current slice of something larger:

DimensionRowsTypeKey strategyWhat it is for
dim_dates214generated, never sourcedmd5(date_day)Every calendar attribute anyone will group by, built wider than the data so a future date already exists.
dim_time_of_day24generated, never sourcedhour_24, a natural integer keyPeak-hour and staffing questions, kept out of dim_dates so the calendar is not multiplied by 24.
dim_users60type 1 - overwritten in placemd5(user_id)Shopper attributes plus a banded tenure_bucket, so no dashboard writes its own CASE.
dim_merchants12type 2 structure, current-version viewmd5(merchant_id)The seller, as it is today. The history lives in merchants_snapshot, which is Part 2.
dim_products60type 1, flat and widemd5(product_id)Product attributes plus merchant_name carried along, so analysts and agents lose a join.
dim_payment_methods6type 1, mappedmd5(payment_method)Raw source strings such as card_visa turned into the grouping people actually say.
LiveWhy a sequence is wrong, in one paragraph you can repeat in review4 min▶

In a hand-loaded warehouse, a sequence is a perfectly reasonable surrogate key: the dimension row is inserted once, gets number 7, and keeps it forever because nothing ever drops the table. dbt does not work that way. materialized: table means drop and recreate in full, every run. So on Monday the sequence hands merchant M07 the number 7, on Tuesday the rows come back in a slightly different order and M07 gets 3 - and every fact row built before Tuesday now points at whichever merchant happens to hold 7 today. Nothing errors. The join still succeeds. The numbers are just attached to the wrong names.

Hashing the business key removes the problem rather than managing it. The key is a pure function of the input, so:

  • It is identical on every run, which is what makes rebuilding the whole warehouse a safe operation instead of a coordinated migration.
  • It is identical on every machine. Your dev target and production compute the same key from the same business key, so you can compare a dev fact table to a production one directly.
  • It needs no state. There is no counter to protect, no gap to explain, and nothing that breaks when two threads build two models at once - which matters, because dbt runs four threads by default.
  • It composes. fct_order_items keys on order_id plus line_no together, and the same macro handles it because it takes a list.
The cost, stated honestly A 32-character string key is wider than an integer and does not compress or join as cheaply. On a large warehouse that is a real bill, and this is exactly where vendor-specific tuning starts - which is out of scope here on purpose, because the project runs on DuckDB so nothing depends on one optimiser. The determinism is worth the width until you can measure that it is not.
LiveFlat and wide, not snowflaked - and the unknown member4 min▶

Two disciplines run through all six dimensions, and both of them are about the person writing the query rather than the person writing the model.

Flat and wide. dim_products carries merchant_name even though dim_merchants already has it. A normaliser would call that a duplicate; a dimensional modeller calls it one less join in every product question anybody ever asks - and one less join in every query an AI agent writes against your warehouse, which is a newer and increasingly load-bearing reason. Snowflaking saves storage that costs nothing and spends clarity that costs a great deal. The same instinct puts tenure_bucket in dim_users and daypart in dim_time_of_day: band it once in the model so eleven dashboards do not each write their own CASE and disagree at the boundaries.

Coalesce to the unknown member. Every dimension lookup in every fact is a LEFT JOIN followed by coalesce(dim.the_sk, '-1'). Read fct_order_items and you will see it four times. The reason is the failure mode you are choosing between:

  • An inner join drops any fact row whose dimension is missing. Revenue silently disappears, the build stays green, and nobody notices until a reconciliation months later. This is the worst failure mode in the whole project, because the evidence of the problem is the thing that got deleted.
  • A left join with no coalesce leaves a null key. Better - the row survives - but now every downstream aggregation has to remember to handle nulls, and half of them will not.
  • A left join coalesced to '-1' keeps the row, keeps the money in the total, and makes the problem countable: select count(*) where merchant_sk = '-1'. A broken lookup becomes a number on a dashboard instead of an absence.

Then you test it. assert_no_fact_rows_lost_to_unknown_members is a singular test that fails the build if the unknown share of fct_order_items climbs above 1%. It currently reports 0.0%, and you will run that measurement in a minute.

Self-studyThree facts at three grains, and the two dimensions you generate4 min read▶

The facts. Three of them, and the grain is declared in the first line of every file because it is the only thing about a fact table you cannot recover by reading the columns:

  • fct_order_items - one row per product line on one order. 1,434 rows. The revenue fact, and where net_amount lives.
  • fct_transactions - one row per payment attempt. 1,060 rows. An order appears several times here: a decline, a retry that succeeds, a refund later.
  • fct_carts - one row per cart. 2,229 rows. Demand that never became revenue, which is what lets you tell "sales fell" apart from "people stopped wanting to buy".

Three grains means three tables. It does not mean three tables that can be joined together, which is session b5's fan-out guard and the reason int_payments_per_order exists. Notice also that the three day-grain marts join pre-aggregated sets rather than facts to facts.

The generated dimensions. dim_dates and dim_time_of_day have no source and no ref() at all. They are built from DuckDB's range():

  • dim_dates walks range(date '2026-03-01', date '2026-10-01', interval 1 day) and produces 214 rows, deliberately wider than the 90 days of data. A calendar dimension that stops where the data stops guarantees a broken lookup the first day new data arrives, and a fact row falling to the unknown member on a date that plainly exists is a maddening bug to chase.
  • dim_time_of_day walks range(0, 24) and produces 24 rows. It is separate from dim_dates on purpose: folding hours into the calendar would multiply it by 24 for no benefit, and "what are our peak hours" needs to group across all 90 days at once.

Never source a calendar from an application database. It will be incomplete, it will have somebody's fiscal calendar baked into it, and it will change without telling you. Generating it means it is correct by construction and reviewable in a diff.

Part 2 · covers dbt documentation on snapshots

Snapshots: the thing a hand-written load could not do 6 min live

Merchant tiers change. Commission rates get renegotiated. A sale made in April earned April's commission, and if the merchant is promoted in June, restating April at June's rate is not a rounding difference - it is a wrong number in a payout report. That is the type-2 problem, and it is the one place in this whole course where dbt does not just manage the same SQL more safely but genuinely does something the hand-written version could not.

merchants_snapshot · strategy: check · check_cols: merchant_tier, commission_pct, merchant_name run 1 · today M03 silver 0.10 dbt_valid_from 2026-07-30 dbt_valid_to NULL One row per merchant. A NULL valid_to means this is the version in force right now. run 2 · later Say M03 is promoted to gold. check_cols sees merchant_tier change, so dbt closes and opens: M03 silver 0.10 dbt_valid_from 2026-07-30 dbt_valid_to 2026-08-14 M03 gold 0.13 dbt_valid_from 2026-08-14 dbt_valid_to NULL Nothing was overwritten. April's fact rows can still find April's commission rate. By hand dim_merchants ships valid_from, valid_to and is_current - the SCD2 structure, correctly designed. Nothing maintains it. Every row is version 1 forever. With dbt One snapshot file. Every run compares, then closes the old version and opens a new one. dbt_valid_from and dbt_valid_to are maintained for you, by the tool. dim_merchants answers "what is true now". The snapshot answers "what was true then". Join facts to the snapshot only when the question is historical - restating a closed month, auditing a rate.
🔍 Click to zoom - each run closes a version instead of overwriting it
LiveThe snapshot file, read line by line5 min▶

snapshots/merchants_snapshot.sql is a SELECT wrapped in two Jinja tags and a config block. That is the entire artifact. The whole of type-2 maintenance is in four config values:

  • unique_key='merchant_id' - the business key. This is how dbt decides that an incoming row and a stored row are the same merchant. Get this wrong and you get duplicate history instead of versioned history.
  • strategy='check' - compare the current values of named columns against what is stored. The alternative is strategy: timestamp, which trusts an updated_at column from the source. Timestamp is cheaper and better when the source is trustworthy; check is what you use when it is not, which in practice is most of the time.
  • check_cols=['merchant_tier', 'commission_pct', 'merchant_name'] - the columns whose change opens a new version. This is a modeling decision, not a technicality: anything not in this list is treated as type 1 and simply overwritten. Merchant country is not in the list, so a country correction rewrites history rather than versioning it - which is the right call for a data fix and the wrong call for a genuine relocation. You are choosing, whether you notice or not.
  • target_schema='snapshots' - snapshots live in their own schema, away from the marts, because they are not something a dashboard should query casually.

Then dbt snapshot runs, and on every subsequent run dbt compares the incoming rows to the stored ones. A changed check_cols value means: set dbt_valid_to on the stored row to now, and insert a new row with dbt_valid_from set to now and dbt_valid_to null. It also maintains dbt_scd_id, a hash identifying the version, and dbt_updated_at. Four columns you never write.

The one rule about snapshots that matters more than all the others A snapshot is the only stateful object dbt builds. Every model can be dropped and rebuilt from source; a snapshot cannot, because the history it holds no longer exists anywhere else. Never --full-refresh a snapshot, back the schema up like production data, and run dbt snapshot on a schedule that is at least as frequent as the changes you care about. History you did not capture is history you do not have.
LiveTwo objects, two questions: dim_merchants versus the snapshot4 min▶

This project deliberately ships both, and the split is worth copying:

  • dim_merchants is the current-version view. Twelve rows, one per merchant, always the version in force, and it carries valid_from, valid_to and is_current so the shape is honest about being a slice of something with history. Every fact in the project joins here, with and dim_merchants.is_current on the join so the pattern still works when history arrives. The mart layer stays readable: twelve rows, no date-range logic in any analyst's query.
  • merchants_snapshot is the history. It grows a row every time a tier or a rate changes and never loses one. It lives in its own schema and nobody queries it by accident.

So when do you join a fact to the snapshot instead of the dimension? The test is whether the question is as-of or as-is:

  • Join the dimension for almost everything. "Revenue by merchant tier this quarter" means today's tiers, because the business asking is thinking about today's portfolio. This is the default and it should stay the default.
  • Join the snapshot when the answer must match what was true at the time: a commission payout being recalculated for a closed month, an audit of what rate was in force on a date, a cohort analysis where the attribute is the cohort. The join carries a date predicate - ordered_at between dbt_valid_from and coalesce(dbt_valid_to, now()) - and returns exactly one version per fact row.

The trap worth naming: a dashboard that silently switches between the two produces two different answers to the same question and no way to tell which one you are looking at. Pick one per report, and put it in the title.

Self-studyThe honest boundary, and what snapshots still cannot do3 min read▶

The hand-written version of this warehouse designed SCD2 properly. Its dim_merchants had valid_from, valid_to and is_current, the grain was right, and the documentation explained the pattern. What it did not have was anything that ran. Maintaining type-2 history by hand means a load that reads the current dimension, diffs it against the incoming rows, UPDATEs the expiring versions and INSERTs the new ones, in a transaction, idempotently, with a plan for the run that fails halfway. That is a genuinely difficult piece of software, and it is why hand-built warehouses so often ship the columns and never fill them. Every row stays version 1 forever and the structure becomes decoration.

dbt reduces that to a file with four config values. That is the single largest capability gap between the two versions of this warehouse, and it is worth being precise about it: everywhere else in this course dbt manages the same SQL more safely. Here it does something else.

What snapshots still will not do for you:

  • They cannot recover history from before the first run. The snapshot starts the day you start it. If tiers changed last year, that is gone unless the source kept it.
  • They only see what the source shows them. With strategy: check, two changes between runs look like one change. A snapshot run daily against an attribute that changes hourly records a daily approximation, and nothing warns you.
  • Deletes are invisible by default. A merchant that disappears from the source keeps its open version forever unless you configure hard_deletes to track it.
  • They do not decide what deserves history. check_cols is a business judgement about which attributes carry meaning over time. Nothing in dbt can make that call, and putting every column in the list produces a new version every time somebody fixes a typo.
Demo 1 of 2

Query the star dbt built ★ 12 min · everyone builds

Real DuckDB, this project already built from dbt's own compiled SQL. Four measurements: the key is really a hash, the unknown member is really empty, the generated dimensions are really wider than the data, and the three facts are really at three grains. One honest note before you start: snapshots are not in the browser database. A snapshot needs dbt snapshot to run, which needs Python, which the browser does not have - so Part 2 is taught from the real file and Demo 2 shows you the dimension side of it. There is no query here against a table that does not exist.

Recompute a key by hand. The macro is md5(concat_ws(...)) and nothing else, so you can reproduce it in the query and compare.

Count the unknown members. Four dimension lookups per fact row, all coalesced to '-1'. If the load is healthy, all four counts are zero.

Check the calendar goes further than the data. 214 generated days against 90 days with sales.

Read the three grains off the row counts. Then remember that this is exactly why they cannot be joined.

SELECT merchant_id,
       merchant_name,
       merchant_sk,
       md5(concat_ws('|', coalesce(cast(merchant_id AS varchar), '_null_'))) AS recomputed_by_hand,
       merchant_sk = md5(concat_ws('|', coalesce(cast(merchant_id AS varchar), '_null_'))) AS matches
FROM main_marts.dim_merchants
ORDER BY merchant_id
LIMIT 6;
SELECT count(*)                                            AS fact_rows,
       sum(CASE WHEN date_key    = '-1' THEN 1 ELSE 0 END)  AS unknown_date,
       sum(CASE WHEN user_sk     = '-1' THEN 1 ELSE 0 END)  AS unknown_user,
       sum(CASE WHEN merchant_sk = '-1' THEN 1 ELSE 0 END)  AS unknown_merchant,
       sum(CASE WHEN product_sk  = '-1' THEN 1 ELSE 0 END)  AS unknown_product
FROM main_marts.fct_order_items;
SELECT (SELECT count(*)        FROM main_marts.dim_dates)              AS generated_days,
       (SELECT min(date_day)    FROM main_marts.dim_dates)              AS calendar_from,
       (SELECT max(date_day)    FROM main_marts.dim_dates)              AS calendar_to,
       (SELECT count(DISTINCT date_key) FROM main_marts.fct_order_items) AS days_with_sales,
       (SELECT count(*)        FROM main_marts.dim_time_of_day)         AS hour_rows;
SELECT 'fct_order_items' AS fact, 'one product line on one order' AS grain, count(*) AS row_count
FROM main_marts.fct_order_items
UNION ALL
SELECT 'fct_transactions', 'one payment attempt', count(*) FROM main_marts.fct_transactions
UNION ALL
SELECT 'fct_carts', 'one cart', count(*) FROM main_marts.fct_carts
ORDER BY row_count DESC;
Real world

The unknown-member discipline is what makes a data quality problem arrive as a number instead of as a silence. Every warehouse eventually receives a fact row whose dimension is missing: a product created after the dimension load, a merchant onboarded mid-batch, a date outside the calendar. With inner joins, that row is deleted and the total quietly shrinks, and the only evidence is a reconciliation nobody runs. With a left join coalesced to '-1', the row survives, the money stays in the total, and the count of unknowns is a metric somebody can put on a dashboard and watch. Same broken load, same day, completely different odds of finding out.

Demo 2 of 2

Your turn: cost an inner join, then read the SCD2 columns ★ 10 min · build your own

The strongest argument for the LEFT JOIN discipline is watching what the alternative costs, on real numbers, in one query.

LiveQ1 · What would an inner join have deleted?5 min▶

Simulate a broken dimension load by hiding one merchant from dim_merchants, then inner-join the fact to it - which is what almost every hand-written star schema does by default. Compare the row count and revenue with the untouched fact table.

WITH dim_without_m07 AS (
    SELECT * FROM main_marts.dim_merchants WHERE merchant_id != 'M07'
)
SELECT (SELECT count(*) FROM main_marts.fct_order_items)                 AS fact_rows,
       count(*)                                                          AS rows_after_inner_join,
       (SELECT round(sum(net_amount), 2) FROM main_marts.fct_order_items) AS revenue_all_rows,
       round(sum(f.net_amount), 2)                                       AS revenue_after_inner_join
FROM main_marts.fct_order_items f
JOIN dim_without_m07 d ON d.merchant_sk = f.merchant_sk;

Now change JOIN to LEFT JOIN and run it again. The row count returns to 1,434 and the revenue returns to the full total, because the unmatched rows survive with a null key - which in the real model is coalesced to '-1' and counted. That two-word difference is the whole discipline.

LiveQ2 · Read the SCD2 columns, and notice what is missing4 min▶

dim_merchants carries the full type-2 column set. Look at the values it currently holds and you can see the difference between designing SCD2 and maintaining it, without needing the snapshot table at all.

SELECT merchant_id,
       merchant_name,
       merchant_tier,
       commission_pct,
       valid_from,
       valid_to,
       is_current
FROM main_marts.dim_merchants
ORDER BY merchant_id
LIMIT 6;

Those three columns are the shape of history with no history in them, which is exactly the state a hand-written warehouse tends to stop at. The snapshot is what fills them: run dbt snapshot on the cloned project, change a merchant tier in data/merchants.csv, reload and snapshot again, and snapshots.merchants_snapshot will hold two rows for that merchant with dbt_valid_to set on the first. That is your homework, and it is the most convincing five minutes in the whole project.

Self-studyQ3 · Which of your own attributes deserve check_cols?3 min▶

No SQL. This is the judgement call snapshots hand back to you, and getting it wrong is expensive in both directions.

Take one dimension in your own warehouse and sort its attributes into three piles:

  • History matters, and somebody would notice. Anything that appears in a rate, a payout, a segment definition, a contract, or a regulatory report. Merchant tier and commission rate are here, which is why they are in this project's check_cols. So are pricing bands, credit ratings, account managers, and any status that gates entitlement.
  • History does not matter. A shopper's city, a display name, a preferred language. Nobody has ever asked for the history of these, so they are type 1 and get overwritten. Putting them in check_cols generates a new version every time somebody fixes a spelling and makes the snapshot useless to read.
  • You genuinely cannot tell. This is the interesting pile, and the answer is not to guess. Ask whoever owns the process whether a change is a correction or an event. Corrections are type 1: the old value was never true. Events are type 2: the old value was true and stopped being true. Country is the classic ambiguous one - a data fix versus an actual relocation - and the two want opposite treatment from the same column.
  • The one to check yourself on: anything currently type 1 that somebody has already asked a historical question about, and got an answer from a rebuilt dimension. That answer was wrong, quietly, and nobody knows.

Bring the first pile to b10. Adding a snapshot to a subject area is part of the capstone, and having already decided which attributes deserve one is most of the work.

Homework

Try it yourself - this week ◐ 20-30 min total

Source material

Sources covered

This page covers:

✓dbt documentation - snapshots, strategy: check, check_cols, the dbt_valid_from and dbt_valid_to columnsPart 2 · taught from this project's real merchants_snapshot.sql, with the stateful-object rule
✓dbt documentation - macros, and surrogate keys as a deterministic hashPart 1 + Demo 1 · macros/surrogate_key.sql written out, then recomputed by hand in a query
◐Kimball & Ross - dimensional patterns: grain, flat-and-wide dimensions, unknown members, SCD2Implemented here, designed in learn-data-modeling · that course owns the theory
◐Incremental models at scale, and warehouse-specific tuning of wide hash keysNamed plainly as out of scope · 985 orders is too small to teach it honestly and the interesting parts are vendor-specific
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Why not use a database sequence for surrogate keys in a dbt project?

A hash of the business key is a pure function of its input: identical every run, on every machine, with no state to keep and nothing to coordinate across four threads. That is what dbt_utils.generate_surrogate_key does, and macros/surrogate_key.sql writes it out so there is no magic in it.

2 · Why does every dimension lookup in these facts use LEFT JOIN plus coalesce(..., '-1')?

Silently-missing revenue is the worst failure mode in the project, because the evidence is the thing that got deleted. Coalescing to the unknown member turns it into a metric: this project measures a 0.0% unknown rate, and a singular test fails the build above 1%.

3 · A merchant's tier changes. With strategy: check and that column in check_cols, what does the next dbt snapshot do?

Each run closes the old version rather than overwriting it, which is why a sale made in April keeps reporting April's commission rate. A snapshot is also the only stateful object dbt builds - never --full-refresh it, because the history it holds exists nowhere else.

Builder session 6 cheat sheet · pin this

Surrogate keysmd5 of the business key. Deterministic, no state, works across threads and machines.
Not a sequencedbt rebuilds from scratch, so a sequence renumbers and old facts point at the wrong rows.
Flat and widedim_products carries merchant_name. One less join for every analyst and every agent.
Unknown memberLEFT JOIN plus coalesce to '-1'. Never an inner join. Currently 0.0% unknown.
Generated dimsdim_dates 214 days via range(), dim_time_of_day 24 rows. Never sourced.
Three grains1,434 order lines · 1,060 payment attempts · 2,229 carts. Never joined to each other.
Snapshotsstrategy: check + check_cols. Each run closes the old version, dbt_valid_from/to.
Stateful, onceThe only object you cannot rebuild. Never full-refresh it. Next: b7, break a model.