learn-data-modeling-with-phoebe / Builder session 10 of 10
Learn Data Modeling with Phoebe · Builder track · Session 10 of 10

Capstone: returns and refunds

Session b1 promised you this: Bazaar can tell you what was ordered and what was paid, and nothing in its eight tables records what was returned. Today you take that subject area down the whole ladder yourself - entities, keys, DDL, a dimension, a fact, a mart, an agent view, three metric definitions - in five timed checkpoints. This page is a brief, not a lecture. It states the acceptance criteria up front, hands you the traps, and then gets out of the way.

🔴 Builder track · hardest Analytics engineers · DE · DS Guided brief · you make the calls 60 min
0-3 · The brief 3-21 · Checkpoints 1-2 · conceptual, keys 21-48 · Checkpoints 3-4 · DDL, dimensional 48-60 · Checkpoint 5 + validate
Part 0

The brief, and how you will know you are done

Bazaar accepts returns. A shopper requests a return on an order line, gives a reason - damaged, wrong item, changed mind, not as described - and the merchant approves or rejects it. An approved return triggers a refund, and the returned unit comes back either resellable or written off. Refunds already exist in transactions as negative amounts with status 'refunded', which is exactly the sort of half-modelled subject area you inherit in real life: the money is recorded, the event is not.

Nine sessions of patterns are your scaffolding. sql/01_oltp_ddl.sql is your DDL style guide, sql/10 and sql/11 are your dimension and fact conventions, sql/40_agent_views.sql is your naming and view contract, and python/validate_model.py is your grader. Nothing here needs a technique you have not already used. What it needs is the judgement to pick the right one, which is the whole point of a capstone.

Live - presented in session Self-study - read after class ▶ Live SQL - editable & runnable Sources covered
★ Acceptance criteria - read these before you write anything One returns table in the OLTP schema at return-line grain, with a constrained reason set, a constrained status set, a composite reference to (order_id, line_no), and one index on the access path you will actually query. One new dimension, dim_return_reason, with an unknown member. One new fact, fact_return, at return-line grain, reusing the existing conformed dimensions rather than inventing new ones. One mart at merchant x day grain carrying returns and return value. One agent view, v_return_line, following the same naming rules as the other five. Three new rows in v_metric_definitions - return_rate, return_value, resellable_share - each with a definition, an expression, a grain and an honest caveat. Two new golden questions with the SQL you expect. Then python python/validate_model.py and all 27 checks green.
Part 1 · the shape of the work

What you are building 3 min live

Four new objects and three new metric rows, bolted onto eight tables, six dimensions, three facts, four marts and five views that all stay exactly as they are. If your design forces you to change something on the left-hand side of this diagram, stop and re-read the grain sentence - it usually means you attached returns to the wrong thing.

What already exists, unchanged (b1-b9) What you add today OLTP source · 8 tables users · merchants · products · carts · cart_items · orders order_items · transactions (refunds live here, unlinked) returns one row per returned order line FK (order_id, line_no) · reason · status Star · 6 dimensions, 3 facts dim_date · dim_time_of_day · dim_user · dim_merchant dim_product · dim_payment_method - all reused as they are fact_order_item · fact_transaction · fact_cart dim_return_reason fact_return one row per return line Serve · 4 marts, 5 views, 13 metric rows mart_merchant_daily · mart_hourly_transactions mart_funnel_daily · mart_payment_health_daily v_sales_line · v_payment_attempt · v_cart · and two more mart_returns_daily v_return_line plus 3 rows in v_metric_definitions Every dimension on the left is reused, not rebuilt. That is what conformance buys you: a brand-new subject area inherits dim_date, dim_merchant, dim_product and dim_user on day one, so "returns by merchant tier" works the moment fact_return lands. Only the reason dimension is genuinely new, because only the reason is genuinely new vocabulary. Adding a second dim_merchant is the failure.
🔍 Click to zoom - four new objects, three new metric rows, nothing on the left touched

Here is the clock. Five checkpoints, each with a deliverable you can hold up and a trap that has cost somebody a quarter of bad reporting. The minutes are tight on purpose: a capstone that lets you gold-plate checkpoint 1 teaches you nothing about sequencing.

The 60 minutes, and what each checkpoint has to produce 1 · 8 min Conceptual - entities and relationships Ships: an entity list, a grain sentence, and the answer to "entity or status on the line?" Trap: modelling a return as a flag, which loses the reason, the approver and the timing. 2 · 10 min Logical and keys Ships: return_id surrogate, the composite reference to (order_id, line_no), the reason set. Trap: storing the refund amount here as well, with nothing that reconciles the two copies. 3 · 12 min Physical DDL Ships: types, NOT NULL, CHECK on both sets, three foreign keys, one deliberate index. Trap: expecting a CHECK to stop a return of 3 units against a line that sold 1. It cannot. 4 · 15 min Dimensional - dim_return_reason and the fact decision Ships: one dimension, one new fact at return-line grain, four conformed dimensions reused. Trap: hanging return measures off fact_order_item, which makes return rate fan out. 5 · 10 min Serve and govern Ships: one mart, one agent view, three metric rows, two golden questions, 27 checks green. Trap: publishing return_rate as a stored ratio, which cannot be re-aggregated by anyone. 8 + 10 + 12 + 15 + 10 = 55 minutes of work, 3 for the brief and 2 to run the validator.
🔍 Click to zoom - the deliverable and the trap for each of the five checkpoints
LiveWork in this order, and do not skip ahead2 min

The ladder is the deliverable as much as the tables are. Every time a team has skipped a rung on a subject area this size, the same thing happens: the DDL gets written first, the conceptual question ("is a return an entity?") gets answered implicitly by whoever typed fastest, and the answer turns out to be wrong three months later when somebody asks for return reasons by merchant.

  • Conceptual before columns. Eight minutes with no SQL editor open. Write the entity list and the grain sentence on paper.
  • Keys before types. Decide what identifies a return and what it points at before you decide whether a timestamp is TEXT or INTEGER.
  • OLTP before dimensional. The star is derived from the source. If the source cannot record the reason, no dimension can invent it.
  • Serve last, and only what someone asked for. One mart, one view, three metrics. A capstone that ships eleven views has misunderstood the exercise.
The one rule that decides most of this page A return is not a property of a sale. It is a separate event, with a separate date, a separate actor and a separate lifecycle, and it can be rejected - which means it can exist with no money and no returned unit attached to it at all. Everything else on this page follows from taking that sentence seriously.
Checkpoint 1 of 5 · 8 min · you decide

Conceptual: entities and relationships ★ deliverable: entity list + grain sentence

No editor, no columns. Two questions, and the rest of the session inherits your answers.

Decide: is a return an entity, or a status on the order line? Apply session b1's test - own identity, own lifecycle, own timestamps - and then apply the question that settles it: can the thing be rejected?

Decide the grain: one return per order, or one per line? Say the sentence out loud: "one row is one ___". If the sentence needs the word "and", you have two entities.

Draw the relationships: which existing entity does a return point at, and what is the cardinality in both directions? Write the verbs, the way b1 did on the conceptual model.

Name one question that becomes unanswerable if you get this wrong. That question is your regression test for the next 50 minutes.

Before you commit to a grain, look at the population a return has to attach to. Every one of these orders has three separate product lines, sold by one merchant, at three different prices.

SELECT order_id,
       COUNT(*)                    AS lines_on_order,
       COUNT(DISTINCT merchant_id) AS merchants_on_order,
       SUM(units)                  AS units_sold,
       ROUND(SUM(net_revenue), 2)  AS order_net_revenue
FROM v_sales_line
WHERE is_paid = 1
GROUP BY order_id
HAVING COUNT(*) >= 3
ORDER BY lines_on_order DESC, order_id
LIMIT 8;
LiveThe answer, once you have committed to yours3 min

A return is an entity. It has its own identity (a return reference the shopper quotes in support tickets), its own lifecycle (requested, then approved or rejected, then received), its own timestamps (requested at, decided at), its own actor (whoever approved it), and - decisively - it can be rejected. A status value on the order line cannot be rejected, because rejecting it would mean setting the line back to a state that erases the fact that anyone ever asked. The request itself is information the business needs to keep.

The grain is one row per returned order line, not one per order. The data above shows why: a shopper who bought three things and sent one back has produced one return against one line. If your grain were the order, you would have to store which line came back somewhere else, and you would be modelling a line-level fact at order level - the same grain error session b5 diagnosed on the flat export, in a new costume.

  • returns : order_items is many-to-one. One line can be returned more than once (a partial return in April, another unit in May), and each is its own event.
  • returns : products is many-to-one, carried on the return the same way order_items carries merchant_id: a point-in-time capture, so the return still reports the right product if the catalogue is reorganised.
  • returns : transactions is many-to-one and optional. A rejected return has no refund. An order refunded in one payment can settle three approved return lines.
Self-studyThe trap: return as a flag2 min read

The shortcut is order_items.is_returned INTEGER DEFAULT 0. It takes ten seconds, it makes "how many returns" answerable, and it destroys four things at once:

  • The reason. "Damaged" and "changed mind" are the same flag, so merchant fault and shopper preference become one number - and the merchant-quality conversation is over before it starts.
  • The approver and the decision. A flag has no actor and no outcome. Rejected requests vanish entirely, so the rejection rate cannot be measured and disputes cannot be audited.
  • The timing. One flag has one implicit date at best. "How long do we take to decide a return" needs two timestamps, and "returns by request week" needs the return's own date, not the order's.
  • Partial and repeat returns. A boolean cannot hold "one of the two units came back", so quantity returned is unrecoverable.

Notice the shape of that list. The flag is not wrong because it is small. It is wrong because it answers exactly one question and forecloses every follow-up, and follow-ups are the entire reason anyone builds a model.

Checkpoint 2 of 5 · 10 min · you decide

Logical and keys ★ deliverable: attributes, keys, the refund relationship

Attributes, keys and cardinality - the rung that turns an entity list into something an analyst can read. Four decisions, and one of them is the sneakiest on this page.

Key: return_id as a surrogate integer. Nothing outside Bazaar owns a return number, and the shopper-facing reference can be derived - so this is the same call session b4 made for order_id, for the same reason.

Reference: a composite (order_id, line_no) pointing at order_items, whose primary key is exactly that pair. Carry product_id alongside it as a point-in-time capture.

Reason: a constrained set of four codes, never free text. Decide the codes now, because every dashboard, dimension and metric downstream inherits them.

Refund: decide how the return relates to transactions - and decide where the refund amount lives. This is the decision people get wrong.

Run this before you decide. Bazaar already has refunds: negative amounts with status 'refunded'. Look at what the refund transaction can and cannot tell you.

SELECT p.attempt_date, p.order_id, p.txn_id,
       ROUND(p.attempt_amount, 2) AS refund_amount,
       s.lines_on_order,
       ROUND(s.order_net_revenue, 2) AS order_net_revenue
FROM v_payment_attempt p
JOIN (SELECT order_id,
             COUNT(*)         AS lines_on_order,
             SUM(net_revenue) AS order_net_revenue
      FROM v_sales_line GROUP BY order_id) s
  ON s.order_id = p.order_id
WHERE p.is_refund = 1
ORDER BY p.attempt_date
LIMIT 10;
LiveWhere the refund amount lives, and why it is a trap4 min

The refund transaction is at payment-attempt grain. It knows an order was refunded 137.12 and it has no idea which of that order's three lines came back, or why, or whether one unit or four were sent back. So the money is recorded and the event is not, which is why this subject area needs modelling at all rather than a view over transactions.

That gives you two defensible designs, and one indefensible one:

  • Defensible - the return points at the refund. returns.refund_txn_id references transactions(txn_id), nullable because a rejected return has none. The amount lives once, on the transaction. Return value is then derived by joining, and a partial refund covering several return lines needs an allocation rule you write down.
  • Defensible - the return carries its own allocated amount. returns.refund_amount holds this line's share, and refund_txn_id holds the reconciliation key. Line-level return value becomes a simple SUM, at the cost of a copy of the money that can drift.
  • Indefensible - both, with nothing that reconciles them. The amount on the return and the amount on the transaction, no key linking them and no check comparing them. Two numbers for the same money, and the day they disagree nobody knows which one finance quoted.

Take the second option, because line-level return value is a metric the business will ask for weekly - but only if you also ship the reconciliation. That means the key (refund_txn_id) plus a validation check asserting that the return lines under one refund sum to that refund's amount. Checkpoint 5 runs exactly that check, and it either agrees to the cent or your model is lying.

The rule for every derived or duplicated money column A copy of a number is allowed. A copy with no reconciliation is not. That is the same discipline validate_model.py applies to net_amount = gross_amount - discount_amount, and it is the reason the star can be trusted against the source at all.
Self-studyThe reason set, and why free text is a modeling decision3 min read

Four codes: damaged, wrong_item, changed_mind, not_as_described. Snake case, singular concept per code, no spaces, no capitals - the naming standard from session a5, applied without exception because the moment one code is Damaged you own a cleaning step forever.

  • A constrained set is a contract with the future. Free text gives you "damaged", "Damaged", "arrived broken", "DMG" and forty singletons, and every one of them is a row that falls out of your GROUP BY.
  • Free text has a place, beside the code, not instead of it. Add reason_note TEXT if support needs the shopper's own words. It is never grouped by, and nothing downstream depends on it.
  • Adding a fifth code is a modeling event. It changes a CHECK constraint, a dimension row and a metric's meaning. That is a feature: the friction is what stops the set silently sprawling to thirty.
  • Never allow a NULL reason. A return with no reason is a row that cannot be grouped, cannot be attributed to merchant fault or shopper choice, and quietly disappears from every reason breakdown. If the source genuinely does not know, that is a code - unknown - not an absence.

Also decide the status set now: requested, approved, rejected, received. Four values, one lifecycle, and a rejected return that stays in the table forever. If you find yourself wanting a fifth status called refunded, stop - that is the transaction's state, not the return's, and putting it here is how two tables start disagreeing about the same fact.

Checkpoint 3 of 5 · 12 min · you build

Physical DDL ★ deliverable: a CREATE TABLE that ships

Now write it, in the style of sql/01_oltp_ddl.sql: every column typed, every rule that can be enforced enforced, and an honest comment where a rule cannot be. The editor below starts on an empty database, so you create everything you query - including the three tables your foreign keys point at.

Types and nullability. Timestamps as ISO-8601 TEXT in UTC, named *_ts. NOT NULL on everything the business always knows at insert time; nullable only where absence is genuinely meaningful - decided_ts before a decision, refund_txn_id when there is no refund.

CHECK the two sets - reason and status - and CHECK quantity_returned > 0. A constraint at the door is worth a hundred cleaning scripts.

Three foreign keys: the composite (order_id, line_no) to order_items, product_id to products, and refund_txn_id to transactions.

One index, chosen deliberately. What will actually be queried? Support looks up returns for an order line; reporting scans by request date. Every index costs write throughput, so name the access path before you add it.

Then break it. Insert a return of 2 units against a line that sold 1, and watch it succeed. Write down where that rule has to live instead.

-- minimal stand-ins for the three parents a return references
CREATE TABLE products (
  product_id   INTEGER PRIMARY KEY,
  product_name TEXT NOT NULL
);
CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  order_ts TEXT NOT NULL
);
CREATE TABLE order_items (
  order_id   INTEGER NOT NULL REFERENCES orders(order_id),
  line_no    INTEGER NOT NULL,
  product_id INTEGER NOT NULL REFERENCES products(product_id),
  quantity   INTEGER NOT NULL CHECK (quantity > 0),
  unit_price REAL    NOT NULL CHECK (unit_price >= 0),
  PRIMARY KEY (order_id, line_no)
);

-- GRAIN: one row per returned order line.
CREATE TABLE returns (
  return_id         INTEGER PRIMARY KEY,
  order_id          INTEGER NOT NULL,
  line_no           INTEGER NOT NULL,
  product_id        INTEGER NOT NULL REFERENCES products(product_id),
  quantity_returned INTEGER NOT NULL CHECK (quantity_returned > 0),
  reason_code       TEXT NOT NULL CHECK (reason_code IN
                      ('damaged','wrong_item','changed_mind','not_as_described')),
  status            TEXT NOT NULL CHECK (status IN
                      ('requested','approved','rejected','received')),
  requested_ts      TEXT NOT NULL,
  decided_ts        TEXT,                    -- NULL while status = 'requested'
  decided_by        TEXT,                    -- the merchant who ruled on it
  refund_txn_id     INTEGER,                 -- NULL when rejected
  disposition       TEXT NOT NULL DEFAULT 'pending' CHECK (disposition IN
                      ('pending','resellable','write_off','not_applicable')),
  FOREIGN KEY (order_id, line_no) REFERENCES order_items(order_id, line_no)
);
CREATE INDEX idx_returns_line ON returns(order_id, line_no);
CREATE INDEX idx_returns_req  ON returns(requested_ts);

INSERT INTO products VALUES (1051,'Cold Brew Pack'), (1007,'Clay Mask');
INSERT INTO orders   VALUES (4,'2026-04-01T13:22:00Z'), (2,'2026-04-01T09:41:00Z');
INSERT INTO order_items VALUES (4,2,1051,1,18.50), (2,1,1007,1,32.90);

INSERT INTO returns
  (return_id, order_id, line_no, product_id, quantity_returned, reason_code,
   status, requested_ts, decided_ts, decided_by, refund_txn_id, disposition) VALUES
  (1, 4, 2, 1051, 1, 'damaged',      'approved', '2026-04-05T09:00:00Z',
      '2026-04-05T14:20:00Z', 'M11', 9001, 'write_off'),
  (2, 2, 1, 1007, 1, 'changed_mind', 'rejected', '2026-04-06T08:30:00Z',
      '2026-04-07T10:00:00Z', 'M02', NULL, 'not_applicable'),
  (3, 4, 2, 1051, 2, 'wrong_item',   'approved', '2026-04-08T09:10:00Z',
      '2026-04-08T12:00:00Z', 'M11', 9002, 'resellable');

-- the rule no CHECK can see: it needs the other table
SELECT r.return_id, r.order_id, r.line_no, r.reason_code, r.status,
       r.quantity_returned, oi.quantity AS quantity_sold,
       CASE WHEN r.quantity_returned > oi.quantity
            THEN 'VIOLATION' ELSE 'ok' END AS single_return_check
FROM returns r
JOIN order_items oi ON oi.order_id = r.order_id AND oi.line_no = r.line_no
ORDER BY r.return_id;

-- and the harder version: the CUMULATIVE rule across every return on the line
SELECT oi.order_id, oi.line_no, oi.quantity AS quantity_sold,
       SUM(r.quantity_returned) AS approved_units_returned
FROM order_items oi
JOIN returns r ON r.order_id = oi.order_id AND r.line_no = oi.line_no
WHERE r.status = 'approved'
GROUP BY oi.order_id, oi.line_no, oi.quantity
HAVING SUM(r.quantity_returned) > oi.quantity;
LiveWhere the over-return rule actually has to live4 min

Both queries above found a violation, and the database accepted both rows without complaint. Be precise about why, because "add a constraint" is the wrong instinct here and knowing that is the point of the checkpoint.

  • A CHECK constraint sees one row of one table. "Returned units must not exceed units sold" needs order_items.quantity, which is a different table. In standard SQL the tool for this is an assertion, and essentially no engine implements assertions. So the constraint cannot be written.
  • The cumulative version is harder still. Even with a magic cross-table CHECK, the real rule is about the sum of every approved return on that line, which is a set, not a row. Returns 1 and 3 above are each individually plausible and jointly impossible.
  • Where it goes, in descending order of preference: (1) the application, inside the same transaction that inserts the return, holding a lock on the line - the only place it can be genuinely prevented; (2) a database trigger, which is closer to the data but easy to forget and awkward to test; (3) a validation check that runs on every load and fails loudly, which detects rather than prevents.
  • Ship the detection either way. Option 3 is not a substitute for option 1, and option 1 is not an excuse to skip option 3 - the source of bad rows is usually a backfill or a support tool that never went through the application.
Real world

Declared is not enforced. SQLite ignores every foreign key in that DDL until PRAGMA foreign_keys = ON is set on the connection, which is session b4's favourite trap and worth re-testing here: the parents exist in this example, so nothing breaks, but a return pointing at a nonexistent line would also insert cleanly. Postgres enforces by default; MySQL depends on the storage engine; a warehouse like Snowflake or BigQuery accepts the syntax and enforces nothing at all, on purpose. Always know which of your constraints are constraints and which are comments.

Self-studyWhich index, and why only one or two2 min read

The DDL above adds two, and both are justified by a named access path rather than a hunch:

  • (order_id, line_no) - support opens an order and asks "has anything here been returned?" This is also the join every load and every validation check uses, so it earns its write cost several times over.
  • requested_ts - the reporting scan. "Returns requested last week" is a range predicate on this column, and without an index it is a full table scan every time the mart rebuilds.

What is deliberately not indexed: reason_code and status, both of which have four values. A four-value column matches roughly a quarter of the table, so the planner will usually scan anyway and you have paid for an index nobody uses. Low cardinality plus a filter that is not selective equals a write tax with no read benefit - which is the whole of session b5's index argument, applied to your own table for the first time.

Checkpoint 4 of 5 · 15 min · you build

Dimensional: one dimension, one fact decision ★ deliverable: dim_return_reason + fact_return

The longest checkpoint, because it contains the single decision that separates a model that answers follow-up questions from one that quietly poisons the numbers you already trust.

Build dim_return_reason in the style of dim_payment_method: a surrogate key, the source code as the business key, a readable label, and a modeled grouping the source does not have. Include the -1 unknown member.

Decide the fact. Is fact_return a new fact at return-line grain, or do return measures belong as extra columns on fact_order_item? Decide before you read the answer, and justify it with the two questions from sql/11_fact_ddl.sql.

List the dimensions you reuse. Which of the existing six does fact_return point at, and which date does date_key mean - the order date or the request date?

Split every ratio. No rate is stored. Decide which numerator and denominator columns you need so that return rate, resellable share and refund value can all be computed at any grain.

The fact decision, and the fan-out it prevents Wrong · return columns on fact_order_item One sale: order 4, line 2, 1 unit, 18.50 net. Two return events against that one line, so the sales row has to be stored twice to hold both. SUM(net_revenue) = 37.00, not 18.50 A rejected return is not a sale, yet it still needs a row - so the sales fact grows rows that never shipped anything and never took any money. One shortcut, and every revenue number built on b7 is now wrong. That is fan-out. Right · fact_return at return-line grain One row per return line, with its own date (the request), its own status, its own reason. fact_order_item is untouched, so every revenue number from b7 stays exactly as it was. Return rate: aggregate each fact to merchant x day, then divide. Two sums at one grain, and never a fact-to-fact join. Rejected returns live here happily: they are returns, they are simply not refunds. The test: can one row of an existing fact be made to appear twice by the new subject area? If yes, the new subject area is a new fact. Different grain, different date, different lifecycle - three separate reasons here, and any one of them on its own would be enough.
🔍 Click to zoom - the fan-out on the left is why the answer is a new fact
-- ---------------- dim_return_reason
-- Small dimension, same shape as dim_payment_method: the source code as the
-- business key, a readable label, and a GROUPING the source does not have.
CREATE TABLE dim_return_reason (
  return_reason_key INTEGER PRIMARY KEY,
  reason_code       TEXT NOT NULL UNIQUE,
  reason_label      TEXT NOT NULL,
  reason_group      TEXT NOT NULL,           -- merchant_fault / shopper_choice
  is_merchant_fault INTEGER NOT NULL CHECK (is_merchant_fault IN (0, 1))
);
INSERT INTO dim_return_reason VALUES
 (-1, 'unknown',          'Unknown',            'unknown',        0),
 (1,  'damaged',          'Damaged in transit', 'merchant_fault', 1),
 (2,  'wrong_item',       'Wrong item shipped', 'merchant_fault', 1),
 (3,  'not_as_described', 'Not as described',   'merchant_fault', 1),
 (4,  'changed_mind',     'Changed mind',       'shopper_choice', 0);

-- a cut-down dim_merchant, standing in for the conformed one you reuse
CREATE TABLE dim_merchant (
  merchant_sk   INTEGER PRIMARY KEY,
  merchant_id   TEXT NOT NULL,
  merchant_name TEXT NOT NULL,
  is_current    INTEGER NOT NULL CHECK (is_current IN (0, 1))
);
INSERT INTO dim_merchant VALUES
 (-1, 'unknown', 'unknown', 1), (11, 'M11', 'Orchid Grocer', 1),
 (4, 'M04', 'Pace Athletics', 1), (1, 'M01', 'Kettle & Co', 1);

-- ---------------- fact_return
-- GRAIN: one row per return line. date_key is the REQUEST date, not the order
-- date - a different date from fact_order_item, which is one of the three
-- reasons this is a separate fact.
CREATE TABLE fact_return (
  return_sk         INTEGER PRIMARY KEY,
  date_key          INTEGER NOT NULL,
  time_key          INTEGER NOT NULL,
  user_sk           INTEGER NOT NULL,
  merchant_sk       INTEGER NOT NULL REFERENCES dim_merchant(merchant_sk),
  product_sk        INTEGER NOT NULL,
  return_reason_key INTEGER NOT NULL REFERENCES dim_return_reason(return_reason_key),
  return_id         INTEGER NOT NULL,        -- degenerate
  order_id          INTEGER NOT NULL,        -- degenerate, the drill-down path
  line_no           INTEGER NOT NULL,        -- degenerate
  refund_txn_id     INTEGER,                 -- reconciliation key, NULL if rejected
  units_returned    INTEGER NOT NULL,        -- additive
  approved_units    INTEGER NOT NULL,        -- additive, 0 when rejected
  resellable_units  INTEGER NOT NULL,        -- additive
  write_off_units   INTEGER NOT NULL,        -- additive
  refund_amount     REAL    NOT NULL,        -- additive, positive, 0 when rejected
  is_approved       INTEGER NOT NULL CHECK (is_approved IN (0, 1)),
  is_rejected       INTEGER NOT NULL CHECK (is_rejected IN (0, 1)),
  UNIQUE (return_id)                         -- the grain, enforced
);
CREATE INDEX idx_fret_date     ON fact_return(date_key);
CREATE INDEX idx_fret_merchant ON fact_return(merchant_sk);

INSERT INTO fact_return VALUES
 (1, 20260405,  9, 12, 11, 51, 1, 1,  4, 2, 9001, 1, 1, 0, 1,  18.50, 1, 0),
 (2, 20260406,  8, 27,  4, 24, 2, 2,  7, 2, 9002, 1, 1, 1, 0, 189.00, 1, 0),
 (3, 20260407, 15, 33,  1, 11, 4, 3, 17, 1, NULL, 1, 0, 0, 0,   0.00, 0, 1),
 (4, 20260409, 11, 48,  1,  6, 3, 4, 15, 2, 9003, 2, 2, 0, 2,  48.00, 1, 0);

-- returns by reason group: merchant fault versus shopper choice, the split the
-- source cannot make and the dimension can
SELECT rr.reason_group, rr.reason_label, rr.is_merchant_fault,
       COUNT(*)                       AS return_lines,
       SUM(f.approved_units)          AS approved_units,
       SUM(f.resellable_units)        AS resellable_units,
       ROUND(SUM(f.refund_amount), 2) AS return_value
FROM fact_return f
JOIN dim_return_reason rr ON rr.return_reason_key = f.return_reason_key
GROUP BY rr.reason_group, rr.reason_label, rr.is_merchant_fault
ORDER BY return_value DESC;

-- and the same fact through a conformed dimension you did not have to build
SELECT m.merchant_id, m.merchant_name,
       SUM(f.is_approved)             AS approved_returns,
       SUM(f.is_rejected)             AS rejected_returns,
       SUM(f.approved_units)          AS approved_units,
       ROUND(SUM(f.refund_amount), 2) AS return_value
FROM fact_return f
JOIN dim_merchant m ON m.merchant_sk = f.merchant_sk
GROUP BY m.merchant_id, m.merchant_name
ORDER BY return_value DESC;
LiveThe answer: a new fact, for three independent reasons4 min

fact_return is a new fact table at return-line grain. Any one of these three would settle it; you have all three:

  • Different grain. One order line can produce two returns, so return rows do not divide evenly into sales rows. Putting them together fans the sale out, which the diagram above prices at 37.00 instead of 18.50 - the same arithmetic as session b9's fact-to-fact double count.
  • Different date. fact_order_item.date_key is when the sale happened; fact_return.date_key is when the return was requested, usually days later and sometimes in a different month. Two dates in one row means one of them is not the row's date, and every time-series query has to guess which.
  • Different lifecycle, including a state with no money. A return can be rejected. A rejected return is a real event with a real reason that the business must be able to count, and it is not a sale event at all. Return 3 above is that row: units returned, zero approved, zero money.

Dimensions reused, not rebuilt: dim_date (on the request date), dim_time_of_day, dim_user, dim_merchant and dim_product. Five of the six existing dimensions carry straight over, which is the whole return on the conformance investment made in b6 - "returns by merchant tier" and "returns by product category" work the moment the fact lands, with no new dimension and no new join rules. Only dim_return_reason is new, because only the reason is new vocabulary.

On the product and merchant keys: use the same surrogate the sale used, so a return reports the merchant tier and list price that were true at the time of sale. Resolving them as at the return date instead would attribute an April sale's return to June's tier, and the two facts would stop agreeing about the same merchant.

Self-studyThe accumulating-snapshot alternative, and why not today3 min read

A return has a lifecycle with milestones: requested, decided, received, refunded. Kimball has a fact type built for exactly that shape - the accumulating snapshot, one row per process instance with a date key per milestone and a lag measure between them, updated in place as the process advances.

It is a genuinely good fit and it is the right answer for some organisations. It is not the answer here, for reasons worth being able to state:

  • It is an update-in-place fact. Rows mutate as milestones land, which is a loading pattern (and a cost model) that belongs to learn-data-warehouse, not to a modeling capstone.
  • The transaction-grain fact is a strict prerequisite anyway. Build the event fact first; the snapshot is derived from it. Doing it the other way round is how teams end up unable to answer anything the snapshot did not anticipate.
  • Bazaar has two milestones, not six. Requested and decided. An accumulating snapshot earns its complexity at five or six milestones with real lag questions between each pair, and pays for itself in shipping-and-fulfilment pipelines rather than here.

Write the decision down either way. "We considered an accumulating snapshot and chose a transaction-grain fact because the lifecycle has two milestones" is the kind of sentence that stops the same debate being reopened every six months - and it belongs in semantic/contract.yaml under known limitations, not in somebody's memory.

Checkpoint 5 of 5 · 10 min · you build

Serve and govern ★ deliverable: mart, view, 3 metrics, 2 golden questions

A fact nobody can query is not finished. Publish it the way b8 and b9 published the rest: one mart at the grain the question is asked at, one self-describing view, metric definitions as data, golden questions as a test suite, and the validator green.

The mart: mart_returns_daily at merchant x day grain, carrying return lines, units returned, return value and resellable units - beside the units and revenue that are its denominators. No stored ratios.

The view: v_return_line, one row per return line, grain in the name, business-readable column names, no exposed path to any other fact. Same five rules as sql/40_agent_views.sql.

Three metric rows: return_rate, return_value, resellable_share, each with a definition, an expression, a source view, a grain and a caveat that is actually honest.

Two golden questions, with the SQL you expect and the wrong answer an ungrounded agent would give.

Then run the validator. python python/validate_model.py. All 27 checks green, or you are not done.

Prototype the mart against real orders before you write any DDL for it. The box below invents seven return lines, attributes them to real settled order lines, rolls them up to merchant x day, and then reconciles the return lines against the refund transactions that already exist in Bazaar.

-- seven return lines, attributed to order lines that really settled, and to the
-- refund transactions that really exist (txn 9, 30, 71, 163)
CREATE TABLE returns_line (
  return_id        INTEGER PRIMARY KEY,
  order_id         INTEGER NOT NULL,
  line_no          INTEGER NOT NULL,
  reason_code      TEXT    NOT NULL,
  units_returned   INTEGER NOT NULL,
  refund_amount    REAL    NOT NULL,
  resellable_units INTEGER NOT NULL,
  refund_txn_id    INTEGER NOT NULL
);
INSERT INTO returns_line VALUES
 (1,   7, 1, 'damaged',          1,  56.90, 0,   9),
 (2,   7, 2, 'wrong_item',       1, 189.00, 1,   9),
 (3,  25, 1, 'not_as_described', 1,  29.61, 0,  30),
 (4,  25, 2, 'changed_mind',     2,  62.51, 2,  30),
 (5,  25, 3, 'changed_mind',     1,  45.00, 1,  30),
 (6,  64, 1, 'wrong_item',       1, 129.00, 1,  71),
 (7, 154, 1, 'damaged',          1,  24.00, 0, 163);

-- mart_returns_daily, prototyped: merchant x SALE date, so the units sold on
-- the same row are the right denominator for return rate
SELECT m.activity_date AS sale_date, m.merchant_id, m.merchant_name,
       m.orders, m.units, m.net_revenue,
       COUNT(r.return_id)                          AS return_lines,
       COALESCE(SUM(r.units_returned), 0)          AS units_returned,
       ROUND(COALESCE(SUM(r.refund_amount), 0), 2) AS return_value,
       COALESCE(SUM(r.resellable_units), 0)        AS resellable_units
FROM mart_merchant_daily m
LEFT JOIN (SELECT v.order_date, v.merchant_id, r.return_id,
                  r.units_returned, r.refund_amount, r.resellable_units
             FROM returns_line r
             JOIN v_sales_line v
               ON v.order_id = r.order_id AND v.line_no = r.line_no) r
       ON r.merchant_id = m.merchant_id AND r.order_date = m.activity_date
GROUP BY m.activity_date, m.merchant_id, m.merchant_name, m.orders, m.units, m.net_revenue
HAVING COUNT(r.return_id) > 0
ORDER BY sale_date;

-- the reconciliation the design from checkpoint 2 owes you
SELECT ROUND((SELECT SUM(refund_amount) FROM returns_line), 2) AS return_line_total,
       ROUND((SELECT -SUM(amount) FROM fact_transaction
               WHERE is_refund = 1
                 AND order_id IN (SELECT DISTINCT order_id FROM returns_line)), 2)
                                                              AS refund_txn_total;
LiveThe date decision the mart forces you to make3 min

The prototype above joins returns to the mart on the sale date, not the request date, and that is a modeling decision you have to make explicitly rather than by accident:

  • By sale date (cohort attribution). "Of the units we sold on 3 April, what share came back?" The numerator and the denominator describe the same units, so the ratio means something. The cost is that the number for recent days keeps rising as returns arrive - it is not final for weeks.
  • By request date (operational load). "How many returns landed on the desk on 9 April?" Final the moment the day ends, and the right number for staffing a returns team. But dividing it by the same day's sales is meaningless, because those are different units.

Both are legitimate metrics. Shipping one and calling it "return rate" without saying which is how two teams end up with two numbers and a meeting. Pick the cohort version for return_rate because it is the one leadership means, keep the request-date count as a separate measure, and put the distinction in the caveat column where an agent and a new analyst will both read it.

Two facts, two grains, one ratio Return rate has its numerator in fact_return and its denominator in fact_order_item. Never join them. Aggregate each to merchant x day first, then divide - which works precisely because both were built from the same conformed dimensions. This is the same rule as b9's forbidden fact-to-fact join, and the reason the mart exists at all.

Now extend the semantic layer. v_metric_definitions has 13 rows; you are adding three. Each caveat should be the sentence you would otherwise have to repeat in Slack every month.

INSERT INTO v_metric_definitions VALUES
 ('return_rate',
  'Share of units sold that were returned and approved, attributed to the sale date.',
  'SUM(approved_units) * 1.0 / SUM(units_sold)',
  'v_return_line + v_sales_line', 'merchant x sale date',
  'Two facts, two grains: aggregate each to merchant x day BEFORE dividing. Never join the facts.'),

 ('return_value',
  'Refund money attributed to approved return lines, as a positive amount.',
  'SUM(refund_amount) WHERE is_approved = 1',
  'v_return_line', 'return line',
  'Sign flipped from fact_transaction, where refunds are negative. Reconcile on refund_txn_id.'),

 ('resellable_share',
  'Share of returned units that came back fit to sell again.',
  'SUM(resellable_units) * 1.0 / SUM(approved_units)',
  'v_return_line', 'return line',
  'Rejected returns have no units, so they leave both sides - they are not counted as zero.');

SELECT metric_name, source_view, grain, caveat
FROM v_metric_definitions
WHERE metric_name IN ('return_rate', 'return_value', 'resellable_share')
ORDER BY metric_name;

SELECT COUNT(*) AS metric_rows FROM v_metric_definitions;
LiveYour two new golden questions3 min

The golden set is a test suite for your model's answerability, so a good new question is one that fails without the artifacts you just shipped. Add these two to semantic/golden_questions.jsonl as q21 and q22, each with the SQL you believe is correct and the wrong answer you expect from an ungrounded agent:

QuestionThe SQL you expectThe ungrounded failure
q21 - "Which return reason costs us the most, and is it our fault?" SELECT reason_group, reason_label, SUM(approved_units), SUM(refund_amount) FROM v_return_line WHERE is_approved = 1 GROUP BY reason_group, reason_label ORDER BY 4 DESC Sums refund_amount over every row, including rejected returns, and reports a reason ranking that includes returns nobody ever refunded.
q22 - "What is our return rate by merchant for May?" Two aggregates to merchant grain - approved units from v_return_line, units sold from v_sales_line - joined on merchant_id, then divided. Joins the two facts on order_id, fans the sales lines out by the number of returns, and reports a rate that is wrong in both the numerator and the denominator.

Then extend semantic/schema_card.md with v_return_line and its grain sentence, and semantic/contract.yaml with the new forbidden join, the three metric definitions, the synonyms the business actually says ("RMA", "refund rate", "send-backs") and the known limitation you wrote down in checkpoint 4. Grade with execution match, exactly as b9 did: run both queries, compare result sets to the cent.

Self-studyThe validation checks your subject area has to add3 min read

The 27 checks in python/validate_model.py are grouped by the order things actually go wrong, and every group has an obvious returns equivalent. Write yours to the same shape - one row, one column, zero means pass - and add them to the CHECKS list:

  • grain - no duplicate return_id in fact_return; no duplicate (activity_date, merchant_id) in mart_returns_daily.
  • keys - dim_return_reason.reason_code unique and never NULL. This is the check that catches a free-text reason sneaking back in.
  • integrity - every fact_return.return_reason_key resolves in dim_return_reason; every merchant_sk, product_sk and date_key resolves too. An orphan return silently disappears from every inner join.
  • additivity - approved_units + rejected units = units_returned; resellable_units + write_off_units never exceeds approved_units; refund_amount = 0 whenever is_rejected = 1; no row both approved and rejected.
  • reconcile - the row count matches returns in the source, and the sum of refund_amount per refund_txn_id equals that transaction's amount. This is the checkpoint-2 promise, enforced.
  • the cross-table rule - approved units returned per order line never exceed units sold. The rule no CHECK could express, detected on every load.
  • nulls - no NULL attributes in dim_return_reason, and no NULL reason_code anywhere.
  • gauge - unknown-member rate on fact_return. It should be 0.0%, the same as every other fact.

Then re-run the whole file. All 27 existing checks must still pass, because you did not touch anything they cover - and if one of them broke, that is the most useful failure of the whole session: it means the new subject area reached into the old model somewhere it should not have.

The grader

How to know you got it right ◐ 2 min

Nine things, checkable by someone else without asking you a single question. That last property is the real test: a model whose correctness depends on its author being in the room is not finished.

CheckWhat passing looks like
1 · Grain sentence"One row of returns is one returned line on one order." No "and", no "or".
2 · Rejection survivesYou can list rejected returns with their reason and their decision time. Nothing about them is inferred.
3 · Sets are constrainedAn INSERT with reason_code = 'Damaged' or status = 'refunded' fails at the door.
4 · The impossible rule is documentedA comment in the DDL says the over-return rule lives in the application, plus a validation check that detects it.
5 · Sales numbers unchangedSUM(net_amount) on fact_order_item is still 111,906.48 on settled lines. Adding returns moved nothing.
6 · Conformance used"Returns by merchant tier" and "returns by product category" work with no new dimension, on the surrogate the sale used.
7 · No stored ratioNothing anywhere holds a return rate. Numerators and denominators only, dividing at read time.
8 · Reconciliation agreesReturn lines under one refund sum to that refund's amount, to the cent, and a check asserts it on every load.
9 · The validator is greenAll 27 original checks pass, your new checks pass, and the unknown-member rate on fact_return is 0.0%.
LiveCommon wrong turns2 min

Four failures account for almost every version of this exercise that goes wrong. Each one runs cleanly, produces numbers, and is discovered months later by an argument in a meeting.

  • Return as a flag. order_items.is_returned. Loses the reason, the approver, the timing, the quantity and every rejected request. Cheap to write, impossible to unwind once six dashboards read it.
  • The refund amount stored twice with nothing reconciling it. An amount on the return and an amount on the transaction, no key between them and no check comparing them. The day they disagree, both numbers are suspect and neither can be defended.
  • return_rate stored as a ratio. A stored rate cannot be re-aggregated: averaging daily rates to a month weights a Tuesday with four sales the same as a Saturday with four hundred. Store the two counts and divide at read time, every time.
  • A NULL reason. One nullable column, and every reason breakdown quietly drops rows while still looking complete. unknown is a code with a dimension row and a surrogate key; NULL is an absence that inner joins delete.
Real world

The fifth wrong turn is scope. The most common way a capstone like this actually fails is not a bad decision - it is eleven views, a second merchant dimension, an accumulating snapshot and a half-finished exchange subject area, none of it validated. The brief asked for four objects, three metric rows and two golden questions. Ship exactly that, green, and then stop. Knowing what to leave out is the senior half of this skill, and no tool will do it for you.

Homework

Try it yourself - beyond this session ◐ 40-60 min total

Source material

Sources covered

The capstone re-applies the whole map rather than adding to it, which is the point of a capstone. Full source map in materials/official-course-map.md. This page covers:

Kimball & Ross, The Data Warehouse Toolkit - grain, conformed dimensions, a new fact at a new grain, additivity, degenerate dimensionsCheckpoint 4 · the fact decision and the five dimensions reused unchanged
Codd / relational theory - entity versus attribute, and 3NF applied to a subject area from scratchCheckpoints 1-2 · entity or flag, and the constrained reason set
ANSI SQL + SQLite / PostgreSQL documentation - CHECK semantics, composite foreign keys, declared versus enforcedCheckpoint 3 · the cross-table rule no CHECK can express
dbt model contracts and the semantic-layer pattern - metrics as data, tests that run on every loadCheckpoint 5 · three metric rows, two golden questions, 27 checks green
Kimball - the accumulating snapshot fact and milestone lag measuresCheckpoint 4 self-study · compared honestly, deferred with a reason
Dehghani, Data Mesh - the contract as the product interface, breaking-change policyHomework · the deprecation policy for your own view
Warehouse load mechanics - applying a type-2 change, incremental loads, update-in-place factsOut of scope by design - learn-data-warehouse builder sessions 4-6
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Which single fact about a return most decisively makes it an entity rather than a status on the order line?

A status value cannot be rejected: rejecting it would mean putting the line back to a state that erases the fact anyone ever asked. Rejected requests carry a reason, an approver and a decision time, and the rejection rate is a real metric. Own identity, own lifecycle and own timestamps all point the same way, but rejectability is the one that settles it.

2 · Why do return measures go in a new fact_return rather than extra columns on fact_order_item?

One line can be returned twice, so the sales row would have to be stored twice and SUM(net_revenue) would double for that line. The return's date is the request date, not the order date. And a rejected return is a real event with no sale attached to it. Any one of the three would be enough; all three together make it obvious.

3 · Where does "approved units returned must never exceed units sold" have to live?

A CHECK sees one row of one table, and this rule needs order_items.quantity plus the sum of every approved return on that line. Prevention belongs in the transaction that inserts the return; detection belongs in the validator, because backfills and support tools bypass the application. Ship both, and say in the DDL comment which one you relied on.

The whole builder track on one card · pin this

The ladderConceptual → logical → physical → dimensional → serve. Five rungs, once per subject area, in that order. b10 proves you can walk it alone.
Grain first"One row is one ___." If the sentence needs "and", you have two tables. Every fan-out, double count and unsummable ratio starts as a missing grain sentence.
Entity or attributeOwn identity, own lifecycle, own timestamps - and can it be rejected? A flag answers one question and forecloses every follow-up.
KeysSurrogate for internal identity, natural where the business owns and prints it, composite where the grain genuinely is the pair.
ConstraintsCHECK enforces single-row rules. Cross-table and cross-row rules live in the transaction, plus a test that detects them. Declared is not enforced.
Conformance pays laterOne dim_date, one dim_merchant, one dim_product. A new subject area then inherits every slice on day one - and a second copy of a dimension is the failure.
New fact, not new columnsDifferent grain, different date, different lifecycle. Any one is enough. Facts meet through conformed dimensions, never through each other.
Publish, then prove itNumerator and denominator, never a stored ratio. Metrics as data with an honest caveat. Golden questions as a test suite. 27 checks green before anyone reads a number.