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

Marts and the business questions

A mart is the star, pre-aggregated to the grain a set of questions is actually asked at, with the metric definitions baked in. It is not a smaller copy of the star and it is not a convenience view. This project ships three of them because Bazaar's questions are asked at three different grains, and merging them would force a grain that fits none. Today you read all three, see how the funnel mart avoids the fan-out that inflates revenue from 111,906.48 to 121,003, and answer the four business questions against dbt-built marts - getting the hand-built course's numbers back to the cent.

🟠 Builder track Analytics engineers · analysts · DE Runs in your browser · real DuckDB 45 min
0-3 · Welcome 3-16 · What a mart is, and what it is not 16-42 · The four questions, against dbt-built marts 42-45 · Q&A
Part 0

The layer people build for the wrong reason

Most marts get built because a dashboard was slow. That is a real reason and a bad definition. A mart earns its existence when a set of questions is asked repeatedly at one grain, and the answers need the same numerator, the same denominator and the same filter every time. Speed is a side effect. The point is that "revenue" means one thing, in one place, computed once.

Sessions b6 and b7 built and tested the star: six dimensions, three facts, one snapshot. This session is the last layer, and it is where the modeling discipline either survives or quietly dies - because a mart is the object a business person points at, and every shortcut in it becomes a number in a board pack.

Live - presented in session Self-study - read after class ▶ Live SQL - real DuckDB, editable Sources covered
★ What you walk out with today A working definition of a mart that is about grain rather than speed, the reason this project has three marts and not one, the fan-out rule applied one layer above where you learned it, the numerator-and-denominator habit for every ratio you ever ship, and the four business questions answered against dbt-built marts with numbers identical to the hand-built warehouse.
Part 1 · covers dbt documentation on materializations, community layering conventions

Three marts, three grains 7 min live

Each mart below exists because a specific group of people asks a specific group of questions at a specific grain. Read the grain first; everything else in the model follows from it.

mart_merchant_daily GRAIN one merchant per day 600 rows - 90 days x 12 merchants asks: which merchant dropped? measures: orders, units, gross, discounts, net, commission, margin mart_funnel_daily GRAIN one day 90 rows - one per calendar day asks: why did revenue move? measures: carts, conversions, orders, revenue, attempts, declines mart_hourly_transactions GRAIN one date-hour 753 rows - only hours with money asks: when does money move? measures: attempts, approvals, declines, amounts, orders touched Why not one wide mart with all of it? Merging them forces a single grain, and there is no grain that serves all three. At merchant-day-hour the funnel questions lose their denominators, because a cart has no merchant-hour. At day, the merchant question disappears entirely. Three marts is not duplication: it is the only arrangement where every measure in each one stays additive over the columns beside it.
🔍 Click to zoom - grain first, everything else follows
MartGrainRowsBuilt fromThe question it exists for
mart_merchant_dailymerchant x day600fct_order_items + dim_merchants + dim_datesWhich merchant dropped, and by how much?
mart_funnel_dailyday90three pre-aggregated day-grain setsWhy did revenue move - carts, conversion or AOV?
mart_hourly_transactionsdate x hour753fct_transactions + dim_dates + dim_time_of_dayWhen does money move, and when is it safe to deploy?
LiveA mart is not a smaller copy of the star4 min▶

Three differences, and each one is a decision somebody has to make on purpose:

  • The grain is chosen from the questions, not from the fact. fct_order_items is at order-line grain because that is what happened. mart_merchant_daily is at merchant-day grain because that is what gets asked. Nobody ever asks a question at line grain; they ask at the grain of a meeting.
  • The metric definitions are baked in. where facts.is_settled lives inside the mart, so "revenue" already means settled money and no dashboard has to remember. That single filter is why the mart total equals the fact total, which is what assert_mart_agrees_with_fact.sql checks on every build.
  • The dimension attributes people group by are flattened in. merchant_name, merchant_category and merchant_tier sit in the mart, so the common query has no joins at all. That is the speed everyone wanted, arrived at as a consequence rather than as the goal.
The test that keeps a mart honest A mart that disagrees with the fact it aggregates is a second source of truth, and a second source of truth is worse than no mart because it looks authoritative. One singular test comparing the two totals, running on every build, is the cheapest governance in the whole project.
Self-studyRatios ship as a numerator and a denominator3 min read▶

Look at the last two columns of mart_merchant_daily:

  • aov_numerator - settled net revenue for that merchant on that day
  • aov_denominator - distinct settled orders for that merchant on that day

There is no aov column, and that is deliberate. A stored ratio is only correct at the grain it was computed at. The moment somebody groups the mart up to a month, or across all merchants, a BI tool will happily average the daily ratios and produce a number that is not the average order value of anything.

The size of the error is easy to measure on this project: sum(aov_numerator) / sum(aov_denominator) gives 116.93, the real AOV over 957 settled orders. Averaging the per-row ratios gives 115.30. That is 1.4% wrong on a friendly dataset with 600 rows and no outliers, and the direction of the error changes with the shape of the data, so you cannot even correct for it.

The rule, stated so you can put it in a review comment Never store a rate, a percentage or an average in a mart. Store both of its parts, additively, and let the last step divide. If a consumer cannot divide, the consumer needs a metric layer, not a pre-divided column.
Self-studyWhy marts are tables and staging models are views2 min read▶

One line in dbt_project.yml decides this for the whole folder:

  • staging: +materialized: view - cheap, always fresh, nothing stored. A staging model is read once by the thing above it.
  • marts: +materialized: table - built once per run, read constantly, by people and dashboards and exports. Paying the compute once at build time instead of once per viewer is the entire argument.

Incremental materialization is the next step up and this project deliberately does not use it: 1,434 fact rows rebuild in milliseconds, and an incremental model you cannot justify is just a correctness risk with extra configuration. The honest trigger for incremental is a full refresh that no longer fits your build window, not a feeling that tables are wasteful.

Part 2 · the b5 lesson, applied at the mart layer

How the funnel mart avoids the fan-out 6 min live

Session b5 taught the rule on intermediate models: never join two facts at different grains, aggregate one of them first. mart_funnel_daily is that rule applied where the temptation is strongest, because a funnel mart looks exactly like a place where you would join carts, orders and payments together and be done.

The join that looks harmless fct_order_items joined to fct_transactions 1,434 order lines x 1,060 payment attempts an order retried twice appears twice revenue becomes 121,002.81 inflation: +9,096.33, and every row is valid What mart_funnel_daily does carts CTE: one row per day sales CTE: one row per day, settled only payments CTE: one row per day then join three day-grain sets revenue stays 111,906.48, to the cent A join is only safe when both sides are at the same grain Neither fact is at day grain, so the mart never touches them together. It reduces each one to the conformed day grain first and only then puts the three results side by side, which is why carts, conversion, orders, revenue and declines can sit in one row without any of them being wrong. The singular test assert_payments_never_fan_out_revenue is exactly this rule, enforced on every build.
🔍 Click to zoom - aggregate first, then join
LiveRead the mart: three CTEs, then a left join4 min▶

The whole model is three aggregations and a join, and the shape is worth memorising because you will write it every month:

  • carts aggregates fct_carts to one row per day: carts created, carts converted, cart value, abandoned value. Demand, including demand that never became money.
  • sales aggregates fct_order_items to one row per day, filtered to settled: paid orders, net revenue, units.
  • payments aggregates fct_transactions to one row per day: attempts, declines, declined amount.
  • The final select starts from carts and left joins the other two, because a day can have carts and no sales but never sales with no carts. Every measure is wrapped in coalesce(..., 0), so a quiet day is a row of zeros rather than a row of NULLs that break arithmetic downstream.

Notice what the mart deliberately does not contain: no conversion_rate, no aov, no decline_rate. It ships carts_created and carts_converted, net_revenue and paid_orders, payment_attempts and declines. Six columns instead of three, and all six are additive.

Self-studyChoosing which fact a mart is built from3 min read▶

mart_hourly_transactions is built from the payments fact and not the sales fact, and the reason is a sentence you should be able to say out loud about every mart you build: the question is about when money moves, and money moves at a payment attempt.

An order has one timestamp, which is when it was placed. A payment attempt has its own timestamp, and a retried order has several. If you answered the peak-hour question from the sales fact you would get the hour people ordered, which is a different question with a different answer and no way to see the declines. The peak hour on this dataset is 21:00 with 96 attempts; the top decline hour is not the same hour, which is the sort of thing only the payments fact can tell you.

The same logic explains why the hour lives in dim_time_of_day rather than in dim_dates: 24 rows once, instead of multiplying the date dimension by 24, and "what are our peak hours across all 90 days" needs to group by hour independently of the date.

Demo 1 of 2

Prove the grains, then prove the fan-out ★ 8 min · everyone builds

These editors run real DuckDB in your browser against this project already built from Bazaar's raw schema with dbt's own compiled SQL. The first Run downloads the engine (about 8 MB, once), then everything is local and every box is editable.

Count the grains. Three marts, three row counts, three different relationships to the 90 days. The row counts alone tell you the grains are genuinely different.

Then break the rule on purpose. Join the two facts directly and watch revenue inflate by 9,096.33 while every single row stays perfectly valid.

Edit both. Change the join key, drop the is_settled filter, add a merchant. The engine is real, so it will tell you the truth.

SELECT 'mart_merchant_daily' AS mart, 'one merchant per day' AS grain,
       count(*) AS mart_rows, count(DISTINCT activity_date) AS distinct_days
FROM main_marts.mart_merchant_daily
UNION ALL
SELECT 'mart_funnel_daily', 'one day',
       count(*), count(DISTINCT activity_date)
FROM main_marts.mart_funnel_daily
UNION ALL
SELECT 'mart_hourly_transactions', 'one date-hour',
       count(*), count(DISTINCT activity_date)
FROM main_marts.mart_hourly_transactions
ORDER BY mart_rows DESC;
WITH from_the_mart AS (
    SELECT round(sum(net_revenue), 2) AS revenue
    FROM main_marts.mart_funnel_daily
),
joined_facts AS (
    SELECT round(sum(f.net_amount), 2) AS revenue
    FROM main_marts.fct_order_items f
    JOIN main_marts.fct_transactions t ON t.order_id = f.order_id
    WHERE f.is_settled
)
SELECT from_the_mart.revenue AS mart_revenue,
       joined_facts.revenue  AS joined_two_facts,
       round(joined_facts.revenue - from_the_mart.revenue, 2) AS invented_revenue
FROM from_the_mart CROSS JOIN joined_facts;
Real world

The 121,003 number is the one that ends up in a board pack. Nobody writes that join to be wrong; they write it because the order id is right there on both facts and the join runs without an error. There is no NULL, no duplicate key and no failed test in the generic suite - the shape is perfect and the meaning is 8.1% too big. This is why the fan-out rule has to be a rule rather than a judgement call, and why the guard against it is a singular test in tests/ that runs on every build rather than a paragraph in a wiki.

Demo 2 of 2

The four questions, against dbt-built marts ★ 16 min · build your own

These are the four questions the hand-built warehouse in learn-data-modeling was built to answer. Same questions, same marts, same numbers - now produced by SQL that is version controlled, dependency ordered and tested on every build. Watch for the fact that nothing in the answers gets better. That is the honest sales pitch.

Run each one and check the number against the caption. If they match, the rebuild is correct; if they ever stop matching, a test in tests/ would have failed first.

Notice how short the SQL is. Every one of these is a group-by over a single mart. That brevity is the mart layer earning its keep.

Then ask a fifth question of your own and see whether an existing mart answers it. If it needs a new grain, you have just discovered a fourth mart.

LiveQ1 · Which merchant dropped, and is it one merchant or the market?4 min▶

The fortnight boundaries come from vars in dbt_project.yml, so no model and no analysis invents its own idea of "the last fortnight". The last column is the one that turns a merchant story into a market story.

WITH fortnights AS (
    SELECT merchant_id, merchant_name,
           sum(CASE WHEN activity_date >= DATE '2026-06-16'
                    THEN net_revenue ELSE 0 END) AS recent_14d,
           sum(CASE WHEN activity_date >= DATE '2026-06-02'
                     AND activity_date <  DATE '2026-06-16'
                    THEN net_revenue ELSE 0 END) AS prior_14d
    FROM main_marts.mart_merchant_daily
    GROUP BY 1, 2
)
SELECT merchant_id, merchant_name,
       round(prior_14d, 0)  AS prior_14d,
       round(recent_14d, 0) AS recent_14d,
       round(100.0 * (recent_14d - prior_14d) / prior_14d, 1) AS pct_change,
       sum(CASE WHEN recent_14d < prior_14d THEN 1 ELSE 0 END) OVER () AS merchants_down_of_12
FROM fortnights
ORDER BY pct_change
LIMIT 5;

Two answers in one result set, which is the whole reason the merchant-day grain exists. Ranked by percentage change you get M07 at -85.2%. Ranked by absolute change you would get M01 Kettle & Co, which lost more money while falling only 12.5%. Both are true; which one you take to a meeting depends on whether you are protecting revenue or protecting a merchant relationship.

LiveQ2 · Why did revenue move - carts, conversion or AOV?4 min▶

This is the decomposition the funnel mart was built for: revenue equals carts times conversion times AOV, so a drop is always one of those three moving and the arithmetic is checkable in one place. Note that AOV is computed here as sum(revenue) / sum(orders) - the numerator-and-denominator discipline paying off in the query rather than in the model.

WITH fortnights AS (
    SELECT CASE WHEN activity_date >= DATE '2026-06-16'
                THEN 'recent' ELSE 'prior' END AS fortnight,
           sum(carts_created)   AS carts,
           sum(carts_converted) AS converted,
           sum(paid_orders)     AS orders,
           sum(net_revenue)     AS revenue
    FROM main_marts.mart_funnel_daily
    WHERE activity_date >= DATE '2026-06-02'
    GROUP BY 1
)
SELECT fortnight,
       carts,
       round(100.0 * converted / carts, 1) AS conversion_pct,
       orders,
       round(revenue, 0)         AS revenue,
       round(revenue / orders, 2) AS aov
FROM fortnights
ORDER BY fortnight DESC;

The answer is a demand problem plus a basket problem, not a checkout problem. That distinction sends the work to marketing and merchandising rather than to engineering, and it is only visible because the mart keeps carts, conversions, orders and revenue at the same grain with all four denominators intact.

LiveQ3 · What happened on the bad day?4 min▶

2026-06-10 is the incident day, and it is a var in the project config for the same reason the fortnights are. The scalar subquery reaches into the payments fact for the reason, which is a drill-down from the mart into the star - exactly the path a mart should leave open.

SELECT f.activity_date,
       f.carts_created,
       f.carts_converted,
       f.paid_orders,
       round(f.net_revenue, 0) AS net_revenue,
       f.payment_attempts,
       f.declines,
       round(100.0 * f.declines / f.payment_attempts, 1) AS decline_rate_pct,
       (SELECT t.decline_reason
          FROM main_marts.fct_transactions t
          JOIN main_marts.dim_dates d ON d.date_key = t.date_key
         WHERE d.date_day = f.activity_date AND t.is_declined = 1
         GROUP BY 1 ORDER BY count(*) DESC LIMIT 1) AS top_decline_reason
FROM main_marts.mart_funnel_daily f
WHERE f.activity_date = DATE '2026-06-10';

The gap between 13 converted carts and 8 paid orders is the whole story, and it is only a gap because the mart holds demand and money side by side. Against the 6.2% baseline decline rate for the whole period, a 52.9% day is not a variation - it is an outage. Change the date and re-run to see what a normal day looks like.

LiveQ4 · When does money move?4 min▶

The hourly mart pre-aggregates the payments fact to date-hour, so this question is a group-by over 753 rows instead of a scan of 1,060 attempts with a timestamp function in the GROUP BY.

SELECT hour_label, daypart,
       sum(attempts)  AS attempts,
       sum(approvals) AS approvals,
       sum(declines)  AS declines
FROM main_marts.mart_hourly_transactions
GROUP BY 1, 2
ORDER BY attempts DESC, hour_label
LIMIT 4;

Add is_weekend to the GROUP BY and you get the answer that actually changes a decision: the weekday peak is 20:00 with 64 attempts and the weekend peak is 21:00 with 41, so a single deploy freeze window is wrong on both kinds of day. The mart keeps day_name and is_weekend flattened in for precisely this follow-up question.

Self-studyWhat changed, now that the answers are identical3 min read▶

No SQL. Every number above matches the hand-built warehouse to the cent, and it should - the SQL computing them is barely different. What changed is everything about how a change to those definitions happens now:

  • A mart change is a reviewable diff. Moving is_settled, adding a merchant tier, changing the fortnight boundary: all of it shows up as a pull request with a before and an after, seen by a second person before it reaches a dashboard. The hand-written version had a definition change look exactly like a whitespace change.
  • The mart cannot silently disagree with the star. assert_mart_agrees_with_fact.sql compares the two totals on every build, and a failure SKIPs everything downstream instead of publishing.
  • The rebuild is one command with a blast radius you can see. dbt build --select marts rebuilds all twelve mart models and runs their tests. dbt build --select mart_funnel_daily+ rebuilds just that mart and everything that depends on it.
  • The definitions are documented where they are implemented. The grain of each mart is the first line of its file, and the same text is in _marts__models.yml and therefore in the generated docs. It cannot drift, because there is only one copy.
Homework

Try it yourself - this week ◐ 25-35 min total

Source material

Sources covered

This page covers:

✓dbt documentation - materializations, table versus view versus ephemeral, config per folderPart 1 self-study · why marts are tables and staging models are views, and when incremental is honest
✓Community layering conventions - the marts layer, one mart per question set, flattening dimension attributesParts 1-2 · three marts read line by line, with the grain of each one stated first
◐Kimball & Ross - grain, additivity, conformed dimensions, aggregate fact tablesRecap only for the marts being implemented · the model itself is learn-data-modeling
◐Metric definitions as a governed artifact (semantic layer, MetricFlow)Named only · this project bakes definitions into marts and ships ratios as parts
◐Scheduling the mart rebuild, and loading the raw tables it depends onOut of scope by design - orchestration is learn-dataops and extraction is learn-data-engineering
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Why does this project have three marts instead of one wide one?

At merchant-day-hour the funnel questions lose their denominators, because a cart has no merchant-hour. At day, the merchant question disappears. Three marts is not duplication - it is the only arrangement where every measure stays additive over the columns beside it.

2 · mart_funnel_daily needs carts, orders and payments in one row. What does it join?

A join is only safe when both sides are at the same grain, and none of the three facts is at day grain. Joining the two facts directly inflates revenue from 111,906.48 to 121,002.81 with no NULL, no duplicate key and no failing generic test - which is why the guard is a singular test.

3 · Why does mart_merchant_daily ship aov_numerator and aov_denominator instead of an aov column?

Store both parts additively and let the last step divide. On this friendly dataset the error is 1.4%, and because its direction depends on the shape of the data you cannot even correct for it. Never store a rate, a percentage or an average in a mart.

Builder session 8 cheat sheet · pin this

What a mart isThe star pre-aggregated to the grain a question set is asked at, with the definitions baked in.
Grain firstmerchant-day (600 rows) · day (90) · date-hour (753). Row counts prove they differ.
Why not one martOne grain cannot serve three question sets. Merging destroys somebody's denominator.
Never join two facts121,002.81 instead of 111,906.48. Aggregate each to the conformed grain first.
Ratios as partsaov_numerator + aov_denominator. Averaging averages gives 115.30, not 116.93.
Marts are tablesOne line in dbt_project.yml. Pay the compute once at build, not once per viewer.
The four answersM07 -85.2% (8 of 12 down) · revenue -30.3% = carts -21.6% x AOV -8.1% · 2026-06-10 gateway_timeout, 52.9% declines · peak 21:00, 96 attempts.
Ship itdbt build --select marts. A mart change is now a reviewable diff. Next: b9, contracts.