skip to content

How do you roll out a fact table grain change from one row per order to one row per order line?

level: seniorimportance: must knowfreq 58%

answer

  1. is this an edit or a new table?
  2. what happens to COUNT(*)?
  3. shipping charge, repeated per line
  4. build both, then prove they agree
  5. the old name can become a rollup

basics

~20 s

Treat it as a new table, not an edit. Build the line-grain fact alongside the order-grain one, reconcile their totals, then rebuild the old table as an aggregate over the new one so consumers keep working while they migrate, and retire it on a published date.

solid answer

~50 s

A grain change is not a modification — every `COUNT(*)`, every average and every join cardinality changes, so the old table and the new one are different things that happen to describe the same business process. Build `fct_order_line` beside `fct_order` rather than mutating it. Decide what happens to measures that are not line-level: order-level shipping and discounts must either be allocated down to lines by a stated rule, or kept in a separate order-grain fact table — duplicating them onto every line is the classic double-counting bug. Keep the order number as a degenerate dimension on the line rows so order-level analysis is still possible. Then reconcile: total revenue by month must match across both tables. Finally, rebuild the old order-grain table as an aggregate over the line-grain one, so there is a single source of truth and existing reports keep returning identical numbers while consumers migrate on a published deadline.

code

text · 11 lines
text
-- fct_order (one row per order)
order_number | order_date | shipping | order_total
   SO-4471   | 2026-03-02 |   9.00   |   209.00

-- fct_order_line (one row per order line)
order_number | line_no | product_sk | extended_price | alloc_shipping
   SO-4471   |    1    |    881     |     150.00     |      6.75
   SO-4471   |    2    |    904     |      50.00     |      2.25

-- SUM(shipping) over the line table would be 9.00, not 18.00,
-- only because shipping was allocated rather than repeated

go deeper

for a junior

Recall that the grain is what one row means, and that changing it changes what COUNT and AVG return. Knowing this is a new table rather than an edit is most of the answer at this level.

for a middle

Explain the mechanics: which measures move to line grain, why an order-level charge repeated per line double-counts, and how a degenerate order number preserves order-level counting.

for a senior

Show the rollout: parallel build, a reconciliation query you would actually run, rebuilding the old table as an aggregate over the new one, and a consumer migration with a published sunset date.

for a principal

Own the decision to do it at all — what analytical questions the finer grain unlocks, what the migration costs in consumer time across teams, and whether an additional fact table would serve the need without disturbing a published one.

## Why a grain change is a different table The grain of a fact table is the business meaning of one row. Change it and you have changed the meaning of every aggregate anyone has ever written against it: - `COUNT(*)` counted orders; now it counts lines. - `AVG(order_total)` averaged over orders; now it averages an order-level value repeated once per line, weighting large orders more heavily. - A join to any dimension now fans out differently. - A measure that was additive at order grain (shipping charge) is now repeated across lines and will double, triple or decuple when summed. No amount of care in the DDL makes this a compatible edit. The professional framing is: `fct_order` and `fct_order_line` are two tables, and the rollout is a migration of consumers between them. ## Step 1 — decide where each measure lives The hardest part is not the rows; it is the measures that do not exist at the finer grain. - **Naturally line-level** (quantity, extended price, unit cost): move down cleanly. - **Order-level but allocatable** (order discount, shipping charge): choose an explicit allocation rule — pro-rata by line extended price is the common one — write the allocated amount into the line row, and prove the allocation sums back to the order value exactly, including the rounding remainder. Never leave a cent unallocated and never let rounding drift. - **Order-level and not allocatable** (order count, a header-level status duration): keep an order-grain fact table for these. Two fact tables at different grains, joined at report time through conformed dimensions, is a correct and normal design, not a failure. A rule of thumb interviewers listen for: *never repeat an order-level value on every line*. It will be summed, and it will be wrong. ## Step 2 — preserve order-level analysis Put the order number on the line rows as a degenerate dimension: a business identifier held in the fact row with no dimension table of its own. Then `COUNT(DISTINCT order_number)` recovers order counts, and average order value becomes `SUM(revenue) / COUNT(DISTINCT order_number)` rather than `AVG(...)`. This is exactly the sort of rewrite that consumers must be told about, because their existing formula still runs and silently returns a different number. ## Step 3 — build side by side and reconcile Run both tables in parallel over the same period and prove the invariants: ```sql SELECT o.order_number, o.order_total, SUM(l.extended_price + l.allocated_shipping) AS line_total FROM fct_order o JOIN fct_order_line l ON l.order_number = o.order_number GROUP BY o.order_number, o.order_total HAVING ABS(o.order_total - SUM(l.extended_price + l.allocated_shipping)) > 0.005; ``` An empty result set is the evidence you take to stakeholders. Also check the grain itself is what you claim — one row per order line, no more: ```sql SELECT order_number, line_number, COUNT(*) FROM fct_order_line GROUP BY order_number, line_number HAVING COUNT(*) > 1; ``` ## Step 4 — make the old table a derived aggregate The move that turns a risky migration into a calm one: once the line-grain table is trustworthy, stop loading the order-grain table from source and rebuild it as an aggregate over the line-grain table. Now there is one pipeline and one source of truth; the old table becomes a compatibility surface returning exactly the numbers it always did. Existing dashboards do not break, and any divergence between the two is impossible by construction rather than a thing you monitor. Where the old table cannot be expressed as an aggregate — because it holds unallocatable header measures — keep it as a genuine order-grain fact table sourced from the same staging layer, and reconcile it on a schedule. ## Step 5 — migrate consumers and retire Enumerate consumers from warehouse query logs and BI lineage rather than from memory. Publish a sunset date for the old table. Give the migrating teams the specific rewrites they need — `COUNT(*)` becomes `COUNT(DISTINCT order_number)`, `AVG(order_total)` becomes the ratio form — because the failure mode is not an error, it is a number that changed. Take the old table out of the consumer-facing view layer before dropping it, so a mistake is a one-line rollback rather than a restore. ## When not to do it at all If only one report needs line detail, an additional line-grain fact table alongside the existing order-grain one may be the whole answer. The atomic grain is the right default for a new model, but retrofitting it onto a heavily consumed published table is a project, and it needs to be justified by the questions it unlocks.

  • What do you do with an order-level shipping charge when you move to line grain?
    Either allocate it down to lines by an explicit rule — usually pro-rata by line value, with the rounding remainder assigned deterministically so the allocation sums back exactly — or leave it in a separate order-grain fact table. What you must not do is repeat the full charge on every line, because it will be summed and inflated by the number of lines.
  • After the change, how do consumers still count orders?
    Carry the order number on the line rows as a degenerate dimension and use COUNT(DISTINCT order_number). Average order value becomes SUM(revenue) divided by that distinct count rather than AVG of a per-line column. Tell consumers explicitly, because their old formulas still execute and just return different numbers.
  • Why rebuild the old order-grain table as an aggregate over the new one instead of keeping both pipelines?
    Two independent pipelines drift, and reconciling them becomes permanent operational work. Deriving the old table from the new one makes agreement structural rather than monitored, leaves one place where business logic lives, and turns the old name into a compatibility surface you can drop once consumers have moved.

saying these in an interview costs you the question

  • Calls a grain change a schema migration and edits in place
  • Repeats order-level shipping on every line row
  • Assumes COUNT(*) still counts orders after the change
  • Runs old and new pipelines independently and hopes they agree
  • Drops the old table on the day the new one goes live

context