An order fact table repeats the order-level shipping charge on every line-item row — what breaks?
answer
- what does one row mean here?
- the value is measured once per order
- a three-line order stores it three times
- the inflation ratio matches lines per order
- allocate it down, or split the table
basics
~20 sSumming the shipping charge multiplies it by the number of lines, because it is measured at order grain, not line grain. Fix it by allocating the charge across lines or moving it to an order-grain fact table.
solid answer
~50 sEvery aggregate over the shipping column inflates. The table's grain is one row per order line, but the shipping charge is measured once per order, so a three-line order stores it three times and `SUM(shipping_amount)` triples it. The damage is not confined to shipping: any report that sums it, any margin calculation that subtracts it, and any drill-down that mixes it with line measures is wrong, usually by a plausible-looking amount that survives review. There are two clean fixes. **Allocate** the charge down to line grain — split it by line amount or weight so the parts sum back to the order total — and store the allocated value as a genuine line-level measure. Or **split the fact table**, keeping line measures at line grain and order-level measures in a second fact table at order grain. The one thing not to do is leave the repeated value in place and expect every query to remember.
code
text · 7 linesorder_id line_no product line_amount shipping_amount
1001 1 A 40.00 9.99
1001 2 B 25.00 9.99
1001 3 C 15.00 9.99
SUM(line_amount) = 80.00 -- correct at line grain
SUM(shipping_amount) = 29.97 -- inflated 3x: charged once per ordergo deeper
Recognise that repeating an order-level amount on every line makes it sum too many times, and that the inflation matches the number of lines on the order.
Explain it as a measure sitting at the wrong grain, and describe both repairs — allocating the value down to line level, or moving it to a separate order-grain fact table.
Diagnose it in production: the ratio-to-lines fingerprint, reconciliation against the source of record, choosing an allocation basis the business will defend, and rounding so parts sum back exactly.
Own prevention rather than cure: a rule that every measure must be measured at the table's declared grain, reconciliation checks on published facts, and a standard for splitting header measures instead of tolerating mixed rows.
## The symptom The first sign is usually a report that is right at one level and wrong at another. Shipping revenue by month looks inflated; shipping per order looks correct. Or gross margin comes out too low, and the gap is proportional to how many lines orders happen to have. ```text order_id line_no product line_amount shipping_amount 1001 1 A 40.00 9.99 1001 2 B 25.00 9.99 1001 3 C 15.00 9.99 SUM(line_amount) = 80.00 -- correct SUM(shipping_amount) = 29.97 -- wrong: the order was charged 9.99 once ``` ## Why it happens The table declares one grain — one row per order line — but carries a measure taken at a coarser grain. The loader had a header value and a line-level target, and the easiest thing to do was copy it down. Nothing in the database objects: the column type is fine, the row count is fine, the joins are fine. Only the meaning is broken, and meaning is exactly what a declared grain is supposed to protect. The general rule this violates: **a measure may sit on a fact row only if it is measured at that row's grain.** A coarser measure copied onto finer rows will be counted once per finer row, which is the definition of double counting. The same failure appears with an order-level discount, a monthly account balance copied onto daily rows, or a campaign budget repeated on every click. ## Why it is worse than an obviously wrong number The inflated value is plausible. Shipping revenue that is 2.4x too high does not look like a NULL or a negative number; it looks like a good quarter. It passes review, gets quoted in a meeting, and is discovered months later when someone reconciles against the finance system. Meanwhile every downstream model built on the table inherits the error. ## Fix one: allocate the measure down to the grain Split the order-level amount across the lines so the parts sum back to the original. The allocation basis should be defensible to the business — line amount, weight, or unit count are the usual choices. ```sql SELECT l.order_id, l.line_no, l.line_amount, h.shipping_amount * l.line_amount / SUM(l.line_amount) OVER (PARTITION BY l.order_id) AS allocated_shipping FROM order_line l JOIN order_header h ON h.order_id = l.order_id; ``` Now every column on the row is a true line-level measurement and `SUM` is safe at any level. Two cautions: rounding must be handled so the allocated parts add back to the header value exactly (give the remainder to the largest line), and the allocation basis becomes a business rule that has to be documented, because someone will eventually ask why line C absorbed $1.87 of shipping. ## Fix two: split the fact table by grain If allocation is not defensible — some order-level values genuinely cannot be attributed to lines — keep two fact tables: line measures at line grain, order-level measures at order grain, one row per order. Each is internally consistent and each can be aggregated freely on its own. A report that needs both aggregates each table separately to a common level and combines the results, rather than joining them row-to-row, which would reintroduce the fan-out. ## Fix three, the one to avoid A tempting third option is to keep one table and add a marker — repeat the value but null it out on all but the first line, or add a `row_type` column and filter. It works right up until someone forgets the filter. It also makes the table's grain unstatable: rows no longer all mean the same thing, so "what does one row represent" has two answers. Every future query carries tribal knowledge, and new consumers learn it by producing a wrong number first. ## Detecting it before a report does Two checks catch nearly all of this class: 1. For every measure column, ask whether the source system records that value at the table's declared grain. Anything sourced from a header, a monthly close, or a parent entity is suspect. 2. Reconcile a coarse aggregate against the source of record: `SUM(shipping_amount)` for a period against the order system's shipping revenue for the same period. A ratio near the average lines-per-order is a fingerprint for this exact defect. ## The wider pattern This is the mixed-grain failure in its simplest form, but the same reasoning covers other shapes: header rows and detail rows stored in one table, one table holding both a per-event row and a per-day summary row, or a fact table that gained a new key column mid-life so old rows and new rows mean different things. In every case the cure is the same — make all rows mean one thing, and if two things need storing, store them in two tables. ## What an interviewer listens for They want the diagnosis stated as a grain mismatch rather than "the SQL is wrong", the ratio-to-line-count fingerprint, and both principled fixes with a reason to prefer one. Candidates who reach for a filter or a `DISTINCT` patch as the primary answer are showing they would leave the defect in the model.
- You suspect this defect but only have the fact table. How do you confirm it quickly?Compare the distinct shipping value per order against the summed one: group by order_id and check whether MIN, MAX and AVG of the column are identical while COUNT is greater than one. A repeated constant across all lines of an order is the fingerprint. Then reconcile a period total against the order system — an inflation ratio close to average lines per order confirms it.
- When is allocating the order-level charge down to lines the wrong choice?When no allocation basis is defensible to the business — a fixed order-handling fee, a per-order regulatory charge, or anything finance insists is not attributable to products. Inventing a split creates numbers that look authoritative but no one will stand behind. Keep those measures in a separate fact table at order grain.
- Why not just store the value once, on the first line of each order, and NULL it elsewhere?Because the rows then mean different things and the table's grain becomes unstatable. Sums happen to work, but joins, filters and row-level reasoning all become conditional, and any consumer who does not know the convention silently gets it wrong. Two clean tables cost less than permanent tribal knowledge.
saying these in an interview costs you the question
- Calls it a query bug rather than a grain mismatch
- Patches it with SELECT DISTINCT on the shipping column
- Stores the value on one line and NULLs the rest
- Joins the line and order fact tables row to row
- Allocates a charge with no basis the business accepts