A diagram somebody already built, with something wrong in it. Say what breaks, when, and to which number — then read the corrected model beside it. Every model on this page was executed, and every number was produced by the schema drawn above it.
This model has been in production for a year and net revenue has always been slightly low. Read the model and say why.
The model as built
Order discount is negotiated once per order and stored on every line of it.
fct_order_linefact
order_idtextDD
line_noint
producttext
line_amountnummeasure
order_discountnummeasurebelongs to the order
one row per line on an order
3 seeded rows
The query the business runs
SELECT SUM(line_amount) AS gross, SUM(order_discount) AS discount,
SUM(line_amount) - SUM(order_discount) AS net_revenue
FROM fct_order_line;
It returns
gross
discount
net_revenue
350
65
285
Why they ask this
It is the most common real modelling defect, it was introduced by a reasonable request, and it under-reports rather than failing — so nobody investigates.
Say this
`order_discount` belongs to the order and is stored on every line of it, so summing it multiplies by the line count. The discount reads 65 instead of 35, and net revenue is short by 30. Move the order-level measure to an order-grain fact, or allocate it down to line grain.
The reasoning
The table declares one row per order line and then carries a measure that is not a property of a line. SO-1 has two lines, so its 30 discount is stored twice and summed twice. Nothing about the table is corrupt: the grain holds, the line measures are right, and only the borrowed column is wrong — and only when it is summed.
That is why it survives review for a year. `MAX(order_discount)` and `AVG(order_discount)` both look plausible, uniqueness tests on (order_id, line_no) pass, and row counts tie to source. The single query that exposes it is the one the finance team runs.
The corrected model splits by grain: line measures stay on the line fact, order measures move to an order fact, and the two are combined by aggregating each to a common grain rather than by joining them. That is drill-across, and the relationship note says so explicitly, because joining the two facts directly would re-create a fan-out of a different shape.
The alternative fix is to allocate the discount down to line grain in proportion to line amount, storing the allocated value. That keeps one table and makes the measure genuinely additive, at the cost of baking an allocation rule and a rounding policy into the data. Either is defensible; leaving the column where it is, is not.
The fix verified against SQLite
The model, corrected
Two grains, two facts. Each measure now lives where it is additive.
fct_order_linefact
order_idtextDD
line_noint
producttext
line_amountnummeasure
one row per line on an order
3 seeded rows
fct_orderfact
order_idtextDD
order_discountnummeasure
one row per order
2 seeded rows
fct_order_line.order_id→ many-to-onefct_order.order_iddrill across at order grain, never join and sum
The same question, asked again
SELECT (SELECT SUM(line_amount) FROM fct_order_line) AS gross,
(SELECT SUM(order_discount) FROM fct_order) AS discount,
(SELECT SUM(line_amount) FROM fct_order_line)
- (SELECT SUM(order_discount) FROM fct_order) AS net_revenue;
Returns
gross
discount
net_revenue
350
35
315
The answer most people give
"Use `SUM(DISTINCT order_discount)`." That happens to work here and breaks the moment two orders have the same discount, at which point it silently under-counts instead of over-counting. It also puts the fix in every query rather than in the model.
They’ll ask next
You allocate the discount across lines by amount and it does not divide evenly. Where does the remainder go, and who signs off on that?
One table records both orders placed and shipments despatched, distinguished by an `event_type` column. What does that cost, and what would you build instead?
The model as built
Two business processes sharing one table, distinguished by a type column.
fct_order_activityfact
event_idintPK
order_idtextDD
event_typetext
amountnummeasure
shipping_costnummeasure
one row per... order? shipment?
4 seeded rows
The query the business runs
SELECT COUNT(*) AS orders_reported,
SUM(amount) AS revenue,
ROUND(SUM(amount) * 1.0 / COUNT(*), 2) AS avg_order_value
FROM fct_order_activity;
It returns
orders_reported
revenue
avg_order_value
4
160
40
Why they ask this
It is the natural result of modelling by source system rather than by business process, and it makes every simple aggregate wrong in a way that is hard to spot.
Say this
The grain cannot be stated in a sentence, so nothing is countable. `COUNT(*)` reports 4 orders when there are 2, and average order value comes out at 40 instead of 80. Split it into one fact per business process, each with its own grain.
The reasoning
Ask what one row means and the table has no answer — some rows are orders and some are shipments. Every measure is therefore NULL for half the rows, `COUNT(*)` counts a mixture, and any denominator computed from the row count is wrong. Average order value here is 160 over 4 rows rather than over 2.
It also forces every downstream query to carry a filter it can silently omit. Analysts must remember `WHERE event_type = 'ordered'` on every aggregate, and the day someone forgets, the number is wrong rather than absent. A model that requires a `WHERE` clause for correctness has moved the grain into a convention.
The fix is one fact per business process. Two tables, two grains, two sentences: one row per order placed, one row per shipment despatched. Both are conformed by order, so combining them is drill-across — aggregate each to order grain, then join the aggregates — which is exactly the discipline the relationship note records.
The counter-argument you will hear is that one table is fewer objects to maintain and lets you see the whole lifecycle in one place. If lifecycle timing is the actual requirement, the right shape is an accumulating snapshot: one row per order with a column per milestone, updated as it progresses. That answers the lifecycle question properly rather than by mixing grains.
The fix verified against SQLite
The model, corrected
One fact per business process, each with a grain you can state in a sentence.
fct_orderfact
order_idtextDD
amountnummeasure
one row per order placed
2 seeded rows
fct_shipmentfact
shipment_idintPK
order_idtextDD
shipping_costnummeasure
one row per shipment despatched
2 seeded rows
fct_shipment.order_id→ many-to-onefct_order.order_idconformed by order; aggregate each side before combining
The same question, asked again
SELECT (SELECT COUNT(*) FROM fct_order) AS orders_reported,
(SELECT SUM(amount) FROM fct_order) AS revenue,
(SELECT ROUND(AVG(amount), 2) FROM fct_order) AS avg_order_value;
Returns
orders_reported
revenue
avg_order_value
2
160
80
The answer most people give
"Add `WHERE event_type = 'ordered'` to the reports." That works when every analyst remembers, forever, in every tool. The grain is a property of the table, and a filter is not a substitute for one.
They’ll ask next
If the business really wants order-to-ship duration, which of the three fact types would you build?
This events table has no grain written down anywhere. It looks fine. What is the first thing you would check, and what would you find?
The model as built
Nobody wrote down what one row means, so nothing can check that it still holds.
events_widefact
user_idtext
event_daydate
session_idtext
page_viewsintmeasure
revenuenummeasure
grain not declared
4 seeded rows
The query the business runs
SELECT COUNT(*) AS rows_loaded,
COUNT(DISTINCT user_id || '|' || session_id) AS sessions,
SUM(revenue) AS revenue_reported
FROM events_wide;
It returns
rows_loaded
sessions
revenue_reported
4
3
120
Why they ask this
An undeclared grain is not a documentation problem, it is the absence of a testable invariant — which means duplicates load silently and nobody can prove they have not.
Say this
Check whether any candidate key is unique. One session is present twice, so the table has 4 rows for 3 sessions and revenue reads 100 instead of 70. With no declared grain there was no uniqueness rule to assert, so nothing caught it.
The reasoning
The diagram shows the symptom directly: no grain sentence on the table. That is worth treating as the finding rather than as a missing comment, because a declared grain is what gives you a key to assert uniqueness on. Without one, a duplicate load is indistinguishable from real data.
Running the check by hand shows 4 rows across 3 distinct sessions, and revenue inflated by 30 — the duplicated session's revenue counted twice. Every ratio built on this table is also wrong, because both numerator and denominator moved by different amounts.
The corrected model declares one row per session and makes `session_id` the key. That single change turns the invariant into something the warehouse can enforce and the pipeline can test — the duplicate now cannot load at all, rather than loading and being discovered by a stakeholder.
The broader point for a critique: a table named for its source (`events_wide`) rather than for a business process is a strong signal that the grain was never chosen. Naming a fact after what it measures — sessions, orders, shipments — usually forces the question that this model skipped.
The fix verified against SQLite
The model, corrected
Grain declared, so session_id is a unique key and the duplicate cannot load.
fct_sessionfact
session_idtextPK
user_idtextFK
event_daydateFK
page_viewsintmeasure
revenuenummeasure
one row per session
3 seeded rows
The same question, asked again
SELECT COUNT(*) AS rows_loaded,
COUNT(DISTINCT session_id) AS sessions,
SUM(revenue) AS revenue_reported
FROM fct_session;
Returns
rows_loaded
sessions
revenue_reported
3
3
70
The answer most people give
"Add a uniqueness test on all the columns." A test on every column catches only exact duplicates and will pass on two genuinely different rows that share an identity. The declared grain tells you *which* columns must be unique, and that is the decision being avoided.
They’ll ask next
Which columns would you pick as the grain here if sessions could legitimately repeat across days?
Inventory is modelled as one row per sku per day. The stock-on-hand figure on the dashboard is 410 and the warehouse holds 140. Where did 410 come from?
The model as built
A periodic snapshot whose measure is labelled like an additive one.
fct_inventoryfact
daydateFK
skutextFK
units_on_handintmeasure
one row per sku per day
6 seeded rows
The query the business runs
SELECT SUM(units_on_hand) AS total_units FROM fct_inventory;
It returns
total_units
410
Why they ask this
Semi-additivity is easy to state and easy to forget, and the resulting number is plausible enough to reach a board pack before anyone questions it.
Say this
It summed a periodic snapshot across time. Three days of stock for two skus adds to 410; the actual holding is the last day's 140. The measure is semi-additive and the model never said so.
The reasoning
The rows are correct and the grain is correct — one row per sku per day is a textbook periodic snapshot. The defect is that `units_on_hand` is a state, not an event, so adding it across days counts the same stock once per day it sat there.
This is the failure mode that makes snapshots dangerous rather than merely large: the table looks exactly like a transaction fact, the column looks exactly like an additive measure, and SQL will sum anything numeric without complaint. Nothing in the schema distinguishes 'units that moved' from 'units that were there'.
The corrected model changes almost nothing physically — the same rows, renamed to say what it is, with the measure annotated as semi-additive — and changes the query to take the latest day. The real fix is that the rule now lives somewhere: in the column note here, and in a semantic layer in production, so that every consumer inherits it instead of each analyst rediscovering it.
Worth adding in an interview: semi-additive measures usually want more than 'last value'. Average daily stock, opening and closing balance, and stock at period end are all legitimate and all different, so the semantic layer should expose named metrics rather than a raw column that anyone can sum.
The fix verified against SQLite
The model, corrected
Same rows. The measure is now declared semi-additive, and queried as one.
fct_inventory_snapshotfact
daydateFK
skutextFK
units_on_handintmeasuresemi-additive: last value over time
one row per sku per day (periodic snapshot)
6 seeded rows
The same question, asked again
SELECT SUM(units_on_hand) AS units_on_the_latest_day
FROM fct_inventory_snapshot
WHERE day = (SELECT MAX(day) FROM fct_inventory_snapshot);
Returns
units_on_the_latest_day
140
The answer most people give
"Add a `WHERE day = current_date` to the dashboard." That fixes one dashboard. The next person to build a report over the same table starts from the same trap, because nothing in the model warned them.
They’ll ask next
The business asks for average stock over the month. Is that the same fix, and what does it do to the semi-additive rule?
Dimension types (conformed, degenerate, junk, role-playing, bridge)Surrogate vs natural keys
Revenue jumped by 100 overnight with no new sales. The only change was a re-run of the product dimension load. What happened?
The model as built
A duplicate load left two rows for sku A. Nothing enforces uniqueness.
dim_productdimension
product_skintPK
skutextNK
categorytext
one row per product... loaded twice
3 seeded rows
fct_salesfact
sale_idintPK
skutextFK
amountnummeasure
one row per sale
2 seeded rows
fct_sales.sku→ many-to-onedim_product.skuthe dimension is not unique on sku, so this fans out
The query the business runs
SELECT d.category, SUM(f.amount) AS revenue, COUNT(*) AS rows_after_join
FROM fct_sales f JOIN dim_product d ON d.sku = f.sku
GROUP BY d.category ORDER BY d.category;
It returns
category
revenue
rows_after_join
tools
200
2
toys
50
1
Why they ask this
Duplicate dimension rows are the most common cause of an overnight revenue jump, and the join that causes it is the most ordinary line of SQL in the warehouse.
Say this
The dimension load ran twice and left two rows for sku A, so every sale of A now matches twice. Revenue for tools reads 200 instead of 100. The dimension is not unique on the key the fact joins to, and nothing enforced that it should be.
The reasoning
A dimension's implicit contract is one row per entity. Break it and every join through that dimension becomes a fan-out — the fact row is duplicated once per matching dimension row, and every measure summed afterwards is multiplied. Two rows for sku A doubles A's revenue, and the join looks completely normal.
The specific weakness here is that the fact joins on the *natural* key. The dimension does have a surrogate primary key, so `product_sk` is unique — but nothing constrains `sku`, which is the column actually used in the join. A primary key that nobody joins on protects nothing.
The corrected model fixes both halves: deduplicate the dimension on its natural key so the contract holds, and have the fact carry `product_sk` so the join is on a column that is unique by construction. After that, a duplicate load cannot fan out even if it happens, because the surrogate cannot repeat.
The operational lesson is worth stating: a uniqueness assertion on every dimension's natural key is the single most valuable test in a warehouse, because this failure is silent, load-time, and inflates money. And a dimension load should be a merge on the natural key rather than an append, so the duplicate never arrives.
The fix verified against SQLite
The model, corrected
Deduplicated on the natural key, and the fact joins on the surrogate instead.
dim_productdimension
product_skintPK
skutextNK
categorytext
one row per product, unique on sku
2 seeded rows
fct_salesfact
sale_idintPK
product_skintFK
amountnummeasure
one row per sale
2 seeded rows
fct_sales.product_sk→ many-to-onedim_product.product_skjoined on the surrogate key, which is unique by construction
The same question, asked again
SELECT d.category, SUM(f.amount) AS revenue, COUNT(*) AS rows_after_join
FROM fct_sales f JOIN dim_product d ON d.product_sk = f.product_sk
GROUP BY d.category ORDER BY d.category;
Returns
category
revenue
rows_after_join
tools
100
1
toys
50
1
The answer most people give
"Add `SELECT DISTINCT` to the report." That removes duplicate rows, not duplicate contributions — and two genuinely identical sales are then silently merged into one, so you have swapped an over-count for an under-count.
They’ll ask next
Would a uniqueness test on `product_sk` have caught this? Which column does the test actually need to be on?
An analyst joins the order-line fact to the payment fact on `order_id` to compare billed against paid. Both tables are correct. The report is not. Why?
The model as built
Both facts are correct. Joining them is what produces the wrong number.
fct_order_linefact
order_idtextDD
line_noint
amountnummeasure
one row per order line
2 seeded rows
fct_paymentfact
payment_idintPK
order_idtextDD
paidnummeasure
one row per payment
2 seeded rows
fct_order_line.order_id→ many-to-manyfct_payment.order_idtwo facts joined directly: 2 lines x 2 payments = 4 rows
The query the business runs
SELECT SUM(l.amount) AS revenue, SUM(p.paid) AS paid
FROM fct_order_line l JOIN fct_payment p ON p.order_id = l.order_id;
It returns
revenue
paid
600
600
Why they ask this
It is the mistake the intuitive approach leads to, and it produces a doubling that scales with the data — two lines and two payments give four rows, ten and ten give a hundred.
Say this
Joining two facts multiplies their rows: 2 lines x 2 payments = 4, so both revenue and paid come out doubled at 600 against a true 300. Aggregate each fact to the common grain first, then join the aggregates.
The reasoning
Each fact is at its own grain and neither is unique on `order_id` — that is the whole point of a fact table. Joining on a column that repeats on both sides produces the cross product within each order, so every line pairs with every payment. Both measures inflate, and they inflate by different factors whenever the two counts differ.
What makes it dangerous is that the answer is not obviously wrong. Revenue and paid both read 600, they still agree with each other, and the reconciliation the analyst was doing appears to pass. A comparison built on two equally-inflated numbers looks healthiest exactly when it is most wrong.
The correct pattern is drill-across: aggregate each fact to a common grain — order, here — in its own subquery, then join the two single-row-per-order results. That join is now one-to-one, so nothing multiplies, and the corrected query returns 300 and 300.
The rule to carry away is that facts are never joined to each other directly; they are conformed through shared dimensions and combined after aggregation. If you find yourself writing `JOIN fct_ ... ON` with another fact on the right, that is the signal.
The fix verified against SQLite
The model, corrected
Same two facts, aggregated to the common grain before they meet.
fct_order_linefact
order_idtextDD
line_noint
amountnummeasure
one row per order line
2 seeded rows
fct_paymentfact
payment_idintPK
order_idtextDD
paidnummeasure
one row per payment
2 seeded rows
fct_order_line.order_id→ many-to-manyfct_payment.order_iddrill across: aggregate each fact to order grain first, then combine
The same question, asked again
WITH lines AS (SELECT order_id, SUM(amount) AS revenue FROM fct_order_line GROUP BY order_id),
pays AS (SELECT order_id, SUM(paid) AS paid FROM fct_payment GROUP BY order_id)
SELECT SUM(lines.revenue) AS revenue, SUM(pays.paid) AS paid
FROM lines JOIN pays ON pays.order_id = lines.order_id;
Returns
revenue
paid
300
300
The answer most people give
"Use `SUM(DISTINCT ...)` on both sides." Distinct sums drop legitimately equal values — two payments of 150 become one — so it under-counts here by exactly the amount it was meant to fix.
They’ll ask next
What if you need payments alongside lines at line grain? Is that question even well-formed?
A customer can belong to two accounts, and the modeller handled it by putting one row per customer-account pair in `dim_customer`. What breaks?
The model as built
A genuine many-to-many pushed into the dimension instead of a bridge.
dim_customerdimension
customer_skintPK
customer_idtextNK
accounttext
one row per customer per account — two rows for C1
2 seeded rows
fct_orderfact
order_idtextDD
customer_idtextFK
amountnummeasure
one row per order
2 seeded rows
fct_order.customer_id→ many-to-onedim_customer.customer_ida customer belongs to two accounts, so every order matches twice
The query the business runs
SELECT SUM(f.amount) AS revenue, COUNT(*) AS rows_after_join
FROM fct_order f JOIN dim_customer d ON d.customer_id = f.customer_id;
It returns
revenue
rows_after_join
320
4
Why they ask this
It is the wrong place to put a many-to-many, and it breaks the dimension's core contract quietly — the dimension still looks like a dimension.
Say this
The dimension is no longer one row per customer, so every order for that customer matches twice and revenue doubles to 320 against a true 160. Move the many-to-many into a bridge with an allocation factor and keep the dimension unique.
The reasoning
A dimension is defined by being unique on the entity it describes. Storing the account relationship inside it changes the grain to one row per customer per account, which nothing in the model records — the table is still called `dim_customer` and is still joined as if it were one row per customer.
The consequence is a fan-out on every fact that touches the dimension, not just on the reports that care about accounts. That is the difference between this and a bridge: a bridge is joined only by the queries that need the many-to-many, whereas a compromised dimension damages everything downstream of it.
The corrected model restores `dim_customer` to one row per customer and puts the pairing in `br_customer_account`, with an allocation factor so revenue by account still totals to real revenue. Queries that do not care about accounts join the dimension and are unaffected; queries that do, opt into the bridge and its weights.
The judgement call underneath is whether the relationship is genuinely many-to-many. If a customer can belong to several accounts *at once*, it is, and it needs a bridge. If they move between accounts over time, that is a Type 2 attribute rather than a bridge — different problem, different answer, and confusing the two is common.
The fix verified against SQLite
The model, corrected
The dimension is unique again; the many-to-many moved to a weighted bridge.
dim_customerdimension
customer_skintPK
customer_idtextNK
one row per customer
1 seeded row
br_customer_accountbridge
customer_idtextFK
accounttext
allocationnum
one row per customer per account, weights summing to 1
br_customer_account.customer_id→ many-to-manydim_customer.customer_idthe many-to-many lives in the bridge, with an allocation factor
The same question, asked again
SELECT SUM(f.amount) AS revenue_unallocated,
(SELECT SUM(f2.amount * b.allocation)
FROM fct_order f2 JOIN br_customer_account b ON b.customer_id = f2.customer_id)
AS revenue_through_bridge
FROM fct_order f;
Returns
revenue_unallocated
revenue_through_bridge
160
160
The answer most people give
"Pick the primary account and store just that." That is a legitimate simplification only if the business agrees to lose the other relationships. Made silently by a modeller, it produces revenue that cannot be reconciled against the account system.
They’ll ask next
How would you tell whether this is a genuine many-to-many or a Type 2 attribute that was modelled wrongly?
Campaign revenue sums to 260 and the company earned 160. The bridge table is correctly built and the join is correct. What is missing?
The model as built
A bridge with no weights. The joins are right; the arithmetic is not.
fct_salesfact
order_idtextDD
amountnummeasure
one row per order
2 seeded rows
br_order_campaignbridge
order_idtextFK
campaigntext
one row per order per campaign
3 seeded rows
br_order_campaign.order_id→ many-to-manyfct_sales.order_idno allocation factor, so any measure summed through it is multiplied
The query the business runs
SELECT b.campaign, SUM(f.amount) AS revenue
FROM fct_sales f JOIN br_order_campaign b ON b.order_id = f.order_id
GROUP BY b.campaign ORDER BY b.campaign;
It returns
campaign
revenue
email
160
paid
100
Why they ask this
Building the bridge is the part everyone knows. The allocation factor is the part that makes the numbers add up, and omitting it is the standard half-finished implementation.
Say this
The allocation factor. Without weights, an order attributed to two campaigns contributes its full amount to each, so the parts sum to more than the whole. Weights that sum to 1 per order give email 110 and paid 50, totalling exactly 160.
The reasoning
The bridge is doing its job — it correctly records that SO-1 belongs to two campaigns. The fan-out that follows is not a bug in the join; it is the arithmetic consequence of one order legitimately belonging to two things. Any measure summed after that join is multiplied by the number of associations.
The individual figures are what make this hard to spot. Email at 160 and paid at 100 are both defensible-looking numbers, and a report showing campaign revenue rarely shows the company total beside it. The error only appears when someone adds the columns up.
The allocation factor turns the association into a weighted one: 0.5 to each campaign for a two-campaign order, 1.0 for a single-campaign order. Multiply before summing and the measure is additive again at every level, with no correction needed in any query.
The genuinely hard part is not technical. The weights encode an attribution model — even split, first touch, last touch, time decay — and marketing and finance will want different ones. Two honest options: name the rule in the column so the choice is visible, or carry several weight columns and let the consumer pick, accepting that two dashboards will then disagree on purpose rather than by accident.
The fix verified against SQLite
The model, corrected
The same bridge, carrying the allocation rule as data.
fct_salesfact
order_idtextDD
amountnummeasure
one row per order
2 seeded rows
br_order_campaignbridge
order_idtextFK
campaigntext
allocationnumeven split; weights sum to 1 per order
one row per order per campaign
3 seeded rows
br_order_campaign.order_id→ many-to-manyfct_sales.order_idweights sum to 1 per order, so campaign revenue totals to real revenue
The same question, asked again
SELECT b.campaign, SUM(f.amount * b.allocation) AS revenue
FROM fct_sales f JOIN br_order_campaign b ON b.order_id = f.order_id
GROUP BY b.campaign ORDER BY b.campaign;
Returns
campaign
revenue
email
110
paid
50
The answer most people give
"Report `COUNT(DISTINCT order_id)` per campaign instead." That is a real metric and it is not the one that was asked for. Revenue by campaign is the question; refusing to answer it is not a fix.
They’ll ask next
Weights must sum to 1 per order. What test would you write, and what should it do when a new campaign is added mid-quarter?
Finance published January revenue as EU 100 / US 50. Re-running the same report in March gives US 150 and no EU row. The fact table has not changed. What is wrong with the model?
The model as built
C1 was in EU in January and moved to US in February. The dimension kept only 'US'.
dim_customerdimension
customer_skintPK
customer_idtextNK
regiontextType 1: overwritten in place
one row per customer, current values only
2 seeded rows
fct_orderfact
order_idtextDD
customer_skintFK
order_daydateFK
amountnummeasure
one row per order
2 seeded rows
fct_order.customer_sk→ many-to-onedim_customer.customer_skregion is Type 1, so January revenue is grouped by today's region
The query the business runs
SELECT d.region, SUM(f.amount) AS january_revenue
FROM fct_order f JOIN dim_customer d ON d.customer_sk = f.customer_sk
WHERE f.order_day < '2026-02-01'
GROUP BY d.region ORDER BY d.region;
It returns
region
january_revenue
US
150
Why they ask this
It is the clearest demonstration that 'losing history' means published numbers change retroactively, and it forces the candidate to name the as-was versus as-is distinction.
Say this
`region` is Type 1, so the overwrite when C1 moved restated every historical order. The report now groups January by March's region. If a report groups by an attribute, that attribute needs Type 2 history and an as-of join.
The reasoning
Type 1 keeps one row per customer, so there is no version for the join to select — the query gets today's region for a transaction from January, and there is no record that it was ever anything else. The earlier number is not merely hard to reproduce; it is unreproducible from this model.
The disappearance of the EU row is the part that causes the incident. A stakeholder comparing the two runs concludes revenue was deleted or misclassified, and every hypothesis they reach for first — a broken load, a dropped partition — is wrong. The cause is a dimension update that nothing logged.
The corrected model makes `region` Type 2 with `valid_from` / `valid_to`, and joins as-of the order date. January is now grouped by January's region permanently, and the March view is still available by joining on `is_current` when someone genuinely wants 'grouped by where they are now'.
The decision rule worth stating: Type 1 is for correcting values that were wrong, Type 2 for recording values that changed. A misspelled name is a correction. A region change is history. The test is whether anyone would ever want the old value back — and if a report groups by it, they will.
The fix verified against SQLite
The model, corrected
Type 2 on region, and an as-of join. January stays as it was published.
dim_customerdimension
customer_skintPK
customer_idtextNK
regiontext
valid_fromdateSCD2
valid_todateSCD2
is_currentboolSCD2
one row per customer per version
3 seeded rows
fct_orderfact
order_idtextDD
customer_idtextFK
order_daydateFK
amountnummeasure
one row per order
2 seeded rows
fct_order.customer_id→ many-to-onedim_customer.customer_idjoined as-of the order day, so January is grouped by January's region
The same question, asked again
SELECT d.region, SUM(f.amount) AS january_revenue
FROM fct_order f
JOIN dim_customer d
ON d.customer_id = f.customer_id
AND f.order_day >= d.valid_from AND f.order_day < d.valid_to
WHERE f.order_day < '2026-02-01'
GROUP BY d.region ORDER BY d.region;
Returns
region
january_revenue
EU
100
US
50
The answer most people give
"Snapshot the report output each month so the published numbers are preserved." That preserves the PDF and leaves the warehouse unable to answer the question. It also means every new historical query is wrong from the day it is written.
They’ll ask next
Converting to Type 2 today — does that recover January's EU figure, or only protect the future?
A Type 2 dimension, an as-of join, and revenue is exactly double for one order. The dimension has two versions and the order is dated 2026-02-01. What is wrong?
The model as built
Version intervals touch at 2026-02-01, and one order falls exactly on it.
dim_customerdimension
customer_skintPK
customer_idtextNK
regiontext
valid_fromdateSCD2
valid_todateSCD2inclusive end — overlaps the next row
one row per customer per version
2 seeded rows
fct_orderfact
order_idtextDD
customer_idtextFK
order_daydateFK
amountnummeasure
one row per order
1 seeded row
fct_order.customer_id→ many-to-onedim_customer.customer_idBETWEEN is inclusive at both ends, so a boundary-dated fact matches both versions
The query the business runs
SELECT COUNT(*) AS rows_after_join, SUM(f.amount) AS revenue
FROM fct_order f
JOIN dim_customer d
ON d.customer_id = f.customer_id
AND f.order_day BETWEEN d.valid_from AND d.valid_to;
It returns
rows_after_join
revenue
2
200
Why they ask this
The model is right and the predicate is subtly wrong, so the bug appears only on version-boundary dates — which is why it survives testing and appears in production.
Say this
`BETWEEN` is inclusive at both ends, and the intervals touch at 2026-02-01, so an order on that exact date matches both versions. Use half-open comparison: `>= valid_from AND < valid_to`.
The reasoning
Consecutive Type 2 versions normally share a boundary — the old row's `valid_to` equals the new row's `valid_from` — because that is what makes the timeline continuous with no gap. With an inclusive comparison on both ends, the boundary instant belongs to two rows, and any fact landing on it fans out.
The failure is rare by construction, which is what makes it dangerous. Only facts dated exactly on a change day are affected, so a test with a handful of rows almost certainly misses it, and the symptom in production is a total that is slightly too high on some days and correct on others.
The fix is the half-open interval: greater-than-or-equal on the lower bound, strictly-less-than on the upper. Every instant then belongs to exactly one version, boundaries included, and the join returns one row. The corrected model records this in the column note, because 'valid_to is exclusive' is a convention a reader cannot infer from the data.
The alternative is to store `valid_to` as the last instant of the old version rather than the first of the new. That works and is worse: it requires a date arithmetic decision (minus one day? one second? what about a time zone?) that varies by column type, and it makes the timeline harder to reason about. Half-open intervals avoid the question entirely.
Whichever you choose, the invariant deserves a test: no two versions of the same natural key may overlap, and there should be no gaps. That test is cheap and catches both this and a whole family of load bugs.
The fix verified against SQLite
The model, corrected
Same intervals, half-open comparison. The boundary belongs to one version only.
dim_customerdimension
customer_skintPK
customer_idtextNK
regiontext
valid_fromdateSCD2
valid_todateSCD2exclusive end
one row per customer per version, half-open intervals
2 seeded rows
fct_orderfact
order_idtextDD
customer_idtextFK
order_daydateFK
amountnummeasure
one row per order
1 seeded row
fct_order.customer_id→ many-to-onedim_customer.customer_id>= valid_from AND < valid_to: every fact matches exactly one version
The same question, asked again
SELECT COUNT(*) AS rows_after_join, SUM(f.amount) AS revenue
FROM fct_order f
JOIN dim_customer d
ON d.customer_id = f.customer_id
AND f.order_day >= d.valid_from AND f.order_day < d.valid_to;
Returns
rows_after_join
revenue
1
100
The answer most people give
"Add `AND d.is_current = 0` to pick the older version." That hard-codes an answer that is only right for this row, and it breaks every fact that legitimately belongs to the current version.
They’ll ask next
Write the assertion that would have caught this at load time. What exactly are you asserting about the intervals?
The order line stores quantity but not amount — revenue is computed as quantity times the product dimension's list price. What happens when pricing changes?
The model as built
The line has no amount — revenue is computed as qty x the dimension's current price.
dim_productdimension
product_skintPK
skutextNK
list_pricenumoverwritten when pricing changes
one row per product, current price only
1 seeded row
fct_order_linefact
order_idtextDD
product_skintFK
qtyintmeasure
one row per order line
1 seeded row
fct_order_line.product_sk→ many-to-onedim_product.product_skrevenue is derived from today's price, so past revenue moves when pricing changes
The query the business runs
SELECT SUM(f.qty * d.list_price) AS revenue_for_a_past_order
FROM fct_order_line f JOIN dim_product d ON d.product_sk = f.product_sk;
It returns
revenue_for_a_past_order
120
Why they ask this
It looks like sensible normalisation and it makes historical revenue a function of today's price list, which is a correctness failure rather than a design preference.
Say this
Historical revenue moves. The order was placed at 10 and the price is now 12, so a past order reports 120 instead of the 100 that was actually charged. The price charged is a fact about the transaction and belongs on the fact.
The reasoning
The instinct — do not store what you can derive — is a good one in an operational schema and wrong here. The list price in the dimension is a *current* attribute of the product; the price charged was a property of the transaction, affected by discounts, contracts and promotions that the dimension does not know about. They were never the same number.
Because revenue is computed rather than stored, every historical figure is recomputed on every run. A price rise silently restates last year, invoices stop reconciling to the warehouse, and there is no version of the data that still agrees with what the customer was billed.
The corrected model stores `unit_price` and `extended_amount` on the fact — the amount actually charged, frozen at the moment of the transaction. The dimension keeps `list_price` as a descriptive attribute, which is now genuinely useful: comparing charged against list is how you measure discounting.
The general principle is worth naming: a fact table records what happened, so anything that was true only at that moment belongs on it. That includes the price, the exchange rate used, the tax rate applied and any attribute the business would consider part of the transaction record. Deriving them later from a dimension makes history depend on the present.
The fix verified against SQLite
The model, corrected
The price charged is a fact about the transaction, so it is stored on the fact.
dim_productdimension
product_skintPK
skutextNK
list_pricenumcurrent price, for reference
one row per product
1 seeded row
fct_order_linefact
order_idtextDD
product_skintFK
qtyintmeasure
unit_pricenummeasurethe price actually charged
extended_amountnummeasure
one row per order line
1 seeded row
fct_order_line.product_sk→ many-to-onedim_product.product_skthe dimension describes the product; the fact records the transaction
The same question, asked again
SELECT SUM(extended_amount) AS revenue_for_a_past_order FROM fct_order_line;
Returns
revenue_for_a_past_order
100
The answer most people give
"Make the price a Type 2 attribute and join as-of." That is genuinely better and still wrong here, because the charged price differs from list for reasons — discounts, contracts — that no version of the price list records.
They’ll ask next
Which other values would you freeze onto the fact for an international order, and why?
A support team's January ticket count shows 2 tickets under sales, a team that handles no tickets. The dimension is correctly Type 2. What is the defect?
The model as built
E1 moved from support to sales in February. All three tickets were closed in January.
dim_employeedimension
employee_skintPK
employee_idtextNK
teamtext
valid_fromdateSCD2
valid_todateSCD2
is_currentboolSCD2
one row per employee per version
3 seeded rows
fct_ticketfact
ticket_idintPK
employee_idtextFK
closed_daydateFK
handledintmeasure
one row per ticket
3 seeded rows
fct_ticket.employee_id→ many-to-onedim_employee.employee_idthe report joins on is_current, so January work follows people to their new team
The query the business runs
SELECT d.team, SUM(f.handled) AS tickets_closed_in_january
FROM fct_ticket f
JOIN dim_employee d ON d.employee_id = f.employee_id AND d.is_current = 1
GROUP BY d.team ORDER BY d.team;
It returns
team
tickets_closed_in_january
sales
2
support
1
Why they ask this
The model is right and the query is wrong, which is the most common way Type 2 fails in practice — and the diagram makes it clear the fault is in the join, not the schema.
Say this
The report joins on `is_current`, so January's work follows people to whatever team they are in today. E1 moved to sales in February, so two January tickets moved with them. Join as-of the day the ticket closed and all three stay with support.
The reasoning
This is the failure that wastes the whole investment in Type 2. The dimension has both versions, the load is correct, the history is there — and the query restricts to one row per employee, which makes the dimension behave exactly like Type 1 while costing more to store and load.
It is worth noticing that the wrong answer is not obviously wrong. Sales showing 2 tickets is only suspicious if you know sales does not handle tickets. In a larger organisation, work quietly migrating between teams as people move is invisible, and headcount-based metrics drift for reasons nobody can trace.
The corrected version changes nothing about the model — the diagram is identical — and only the join predicate differs, matching on the natural key with the ticket's close date falling inside the version's validity window. That is the whole fix, and its size relative to the damage is the point of the question.
The durable answer is to stop leaving the choice to the query. Resolve the version at load time so the fact carries the surrogate key of the correct version, and the as-of logic exists in exactly one place. Where that is not possible, define both metrics in the semantic layer under distinct names, so 'by team at the time' and 'by team now' are chosen deliberately rather than by whichever predicate someone typed.
The fix verified against SQLite
The model, corrected
Identical model. Only the join predicate changed.
dim_employeedimension
employee_skintPK
employee_idtextNK
teamtext
valid_fromdateSCD2
valid_todateSCD2
is_currentboolSCD2
one row per employee per version
3 seeded rows
fct_ticketfact
ticket_idintPK
employee_idtextFK
closed_daydateFK
handledintmeasure
one row per ticket
3 seeded rows
fct_ticket.employee_id→ many-to-onedim_employee.employee_idas-of the day the ticket closed: the work stays with the team that did it
The same question, asked again
SELECT d.team, SUM(f.handled) AS tickets_closed_in_january
FROM fct_ticket f
JOIN dim_employee d
ON d.employee_id = f.employee_id
AND f.closed_day >= d.valid_from AND f.closed_day < d.valid_to
GROUP BY d.team ORDER BY d.team;
Returns
team
tickets_closed_in_january
support
3
The answer most people give
"The dimension load is wrong — E1 should not have two rows." Two rows is exactly correct for Type 2, and it is what makes the right answer obtainable. The defect is entirely in the join.
They’ll ask next
A ticket closed on the day E1 changed team. Which version does your predicate pick, and is that the right one?
This Type 2 dimension uses `customer_id` as its primary key and the fact joins on it. One order, one customer, and revenue reads 200. Explain.
The model as built
Type 2 history with the natural key as the only key. One order, two versions.
dim_customerdimension
customer_idtextPKnatural key used as the primary key
regiontext
valid_fromdateSCD2
valid_todateSCD2
one row per customer per version — so the key is not unique
2 seeded rows
fct_orderfact
order_idtextDD
customer_idtextFK
amountnummeasure
one row per order
1 seeded row
fct_order.customer_id→ many-to-onedim_customer.customer_idthe fact points at a customer, not at a version, so it matches every version
The query the business runs
SELECT COUNT(*) AS rows_after_join, SUM(f.amount) AS revenue
FROM fct_order f JOIN dim_customer d ON d.customer_id = f.customer_id;
It returns
rows_after_join
revenue
2
200
Why they ask this
It is the concrete demonstration of why surrogate keys exist, and it shows that the reason is correctness rather than join performance.
Say this
With history, one `customer_id` maps to several rows, so it is not a key at all. The fact points at a customer rather than at a version, matches both, and doubles. A surrogate key per version, resolved at load time, makes the join an ordinary equality.
The reasoning
The primary-key declaration is a claim the data cannot honour: the moment a second version is written, `customer_id` repeats. The diagram states the grain as one row per customer per version, which is directly at odds with the key — and that contradiction is the finding.
The fan-out follows automatically. A fact row joining on `customer_id` matches every version of that customer, so its measures are counted once per version. A customer with five historical changes multiplies their revenue by five, which means the error grows with how much history you keep.
The corrected model gives each version its own surrogate key and has the load resolve which version applies when the fact is written. The fact then references one specific version, the join is a plain equality on a unique column, and there is no as-of predicate for anyone to get wrong.
This is the strongest argument for surrogate keys and the one most often left out in favour of 'integers join faster'. Speed is a secondary benefit. The primary reason is that the natural key stops being unique the moment you keep history, and a fact needs to point at a version rather than at an entity.
The fix verified against SQLite
The model, corrected
A surrogate key per version, resolved when the fact is loaded.
dim_customerdimension
customer_skintPK
customer_idtextNK
regiontext
valid_fromdateSCD2
valid_todateSCD2
one row per customer per version
2 seeded rows
fct_orderfact
order_idtextDD
customer_skintFKresolved at load time
amountnummeasure
one row per order
1 seeded row
fct_order.customer_sk→ many-to-onedim_customer.customer_skthe fact points at one version, so the join is an ordinary equality
The same question, asked again
SELECT COUNT(*) AS rows_after_join, SUM(f.amount) AS revenue
FROM fct_order f JOIN dim_customer d ON d.customer_sk = f.customer_sk;
Returns
rows_after_join
revenue
1
100
The answer most people give
"Add the as-of predicate to the join and the existing keys are fine." That does fix the number, and it leaves a primary key that is not unique and a fact that cannot say which version it meant. Every future query has to re-derive it correctly.
They’ll ask next
Where does the load look up the right version, and what does it do for a fact that arrives before the dimension row exists?
The usage fact keys rows on `account || '-' || plan`. Two genuinely different rows have collapsed into one key. How, and what would you do instead?
The model as built
Two genuinely different (account, plan) pairs concatenate to the same string.
fct_usagefact
usage_keytextPKaccount || '-' || plan
accounttext
plantext
unitsintmeasure
one row per account per plan
2 seeded rows
The query the business runs
SELECT COUNT(*) AS rows_loaded,
COUNT(DISTINCT usage_key) AS distinct_keys,
COUNT(DISTINCT account || '|' || plan) AS distinct_pairs,
SUM(units) AS units
FROM fct_usage;
It returns
rows_loaded
distinct_keys
distinct_pairs
units
2
1
2
30
Why they ask this
Concatenated keys are everywhere in warehouse code, and the collision is data-dependent — it works until a value happens to contain the delimiter.
Say this
Account `A-1` with plan `PRO` and account `A` with plan `1-PRO` both concatenate to `A-1-PRO`. Two rows, one key. Key on the columns themselves — a composite key or a tuple — so there is no delimiter to collide on.
The reasoning
String concatenation throws away the boundary between the parts. Any value that can contain the delimiter makes the mapping ambiguous, and account codes, SKUs and plan names from upstream systems routinely contain hyphens. The query shows it directly: 2 rows, 2 distinct pairs, and only 1 distinct key.
What makes it dangerous is that the collision does not raise. Depending on how the key is used, it either merges two entities into one — losing rows in a dedup or a merge — or fails a uniqueness assertion for reasons that look impossible, because the underlying columns really are distinct.
The corrected model declares the composite key on the columns directly. Every warehouse supports a multi-column key or unique constraint, so there is no reason to flatten it into a string, and comparisons then respect the boundaries.
Where a single-column key is genuinely required — as a join key, or as a hash key in a Data Vault or lakehouse model — the safe construction is a hash over the parts with an unambiguous separator: a character that cannot occur in the data, or length-prefixed encoding, and a documented rule about NULLs. `MD5(a || '|' || b)` has exactly the same collision problem as the plain concatenation if `|` can appear.
The fix verified against SQLite
The model, corrected
No concatenation, so no delimiter to collide on.
fct_usagefact
accounttextPK
plantextPK
unitsintmeasure
one row per account per plan, keyed on the columns themselves
2 seeded rows
The same question, asked again
SELECT COUNT(*) AS rows_loaded,
COUNT(DISTINCT account || '|' || plan) AS distinct_pairs,
SUM(units) AS units
FROM fct_usage;
Returns
rows_loaded
distinct_pairs
units
2
2
30
The answer most people give
"Use a delimiter that will not appear in the data, like a pipe or a tilde." That makes it rarer rather than impossible, and you do not control what an upstream system puts in a free-text field. It also fails silently when it does happen.
They’ll ask next
You need a single-column hash key for a lakehouse merge. How do you build it so this cannot happen?
Orders total 220 in the source and 170 on every dashboard. All three orders exist in the fact table. Where are the missing 50?
The model as built
One order arrived with no channel. The dimension has nowhere to put it.
dim_channeldimension
channel_skintPK
channeltext
one row per channel
2 seeded rows
fct_orderfact
order_idtextDD
channel_skintFKNULL when the source did not say
amountnummeasure
one row per order
3 seeded rows
fct_order.channel_sk→ many-to-onedim_channel.channel_ska NULL key matches nothing, so those orders vanish from every joined report
The query the business runs
SELECT SUM(f.amount) AS revenue_reported, COUNT(*) AS orders_reported
FROM fct_order f JOIN dim_channel d ON d.channel_sk = f.channel_sk;
It returns
revenue_reported
orders_reported
170
2
Why they ask this
It is the single most common cause of a warehouse total that does not tie to source, and the model — not the query — is where it should be fixed.
Say this
One order has a NULL channel key, so the inner join to `dim_channel` drops it. The rows exist and never survive a join. Add an unknown member to the dimension and point unmatched facts at it, so the foreign key is never NULL.
The reasoning
A NULL never equals anything, so an inner join discards the row and every measure on it. The fact table is intact and the reports are short — which is exactly why 'the warehouse total does not match source' is such a common and confusing complaint. Nothing errors and the fact row is right there when you query the table directly.
Telling analysts to use a LEFT JOIN is not a fix, because it has to be remembered at every call site and BI tools generate inner joins by default. A model whose correctness depends on every consumer choosing the right join type has moved the invariant out of the data.
The corrected model adds a reserved row — key -1, 'Unknown' — and routes unmatched facts to it during the load, so the foreign key is never NULL. Totals now tie, the join can be an inner join, and the unmatched volume appears as a visible bucket someone can be asked about rather than as an absence nobody can see.
Two refinements worth mentioning. Distinguish 'not applicable' from 'not yet arrived' with different reserved keys, so the second can be monitored and repaired as a late-arriving dimension. And declare the foreign key NOT NULL once the unknown member exists, so the model enforces what the load promises.
The fix verified against SQLite
The model, corrected
The unmatched order points at the unknown member instead of at nothing.
dim_channeldimension
channel_skintPK
channeltext
one row per channel, plus a reserved unknown member
3 seeded rows
fct_orderfact
order_idtextDD
channel_skintFKnever NULL
amountnummeasure
one row per order
3 seeded rows
fct_order.channel_sk→ many-to-onedim_channel.channel_skevery fact points at a real dimension row, so totals tie and the gap is visible
The same question, asked again
SELECT SUM(f.amount) AS revenue_reported, COUNT(*) AS orders_reported
FROM fct_order f JOIN dim_channel d ON d.channel_sk = f.channel_sk;
Returns
revenue_reported
orders_reported
220
3
The answer most people give
"Change the reports to LEFT JOIN." Correct for the reports you change, and a trap for every one written afterwards. The fix belongs in the load and the dimension, where it applies once.
They’ll ask next
What would you put in the unknown row’s other attributes, and how would you monitor how many facts point at it?
Surrogate vs natural keysDimension types (conformed, degenerate, junk, role-playing, bridge)
Store keys look like `EU-FLAG-01`, and reports slice by region using `substr(store_key, 1, 2)`. A newly acquired store kept its own code. What does the regional report show?
The model as built
The key encodes region and format. An acquired store kept its own code.
dim_storedimension
store_keytextPKREGION-TYPE-NUMBER, parsed by reports
openeddate
one row per store
4 seeded rows
fct_salesfact
store_keytextFK
amountnummeasure
one row per sale
4 seeded rows
fct_sales.store_key→ many-to-onedim_store.store_keyregion is parsed out of the key with substr, so re-badging a store rewrites history
The query the business runs
SELECT substr(d.store_key, 1, 2) AS region, SUM(f.amount) AS revenue
FROM fct_sales f JOIN dim_store d ON d.store_key = f.store_key
GROUP BY region ORDER BY region;
It returns
region
revenue
AC
25
EU
160
US
40
Why they ask this
Smart keys feel efficient and make the key a parser problem. The failure arrives with the first entity that does not fit the scheme, which is always eventually.
Say this
A region called `AC`, holding 25 of revenue, because the acquired store's code is `ACME-07`. Attributes parsed out of a key are attributes that cannot change and cannot have exceptions. Store the code as a natural key and make region a column.
The reasoning
The key was doing two jobs: identifying a store and describing it. That works while every code follows the scheme, and the first exception produces a silent new category. Nothing errors — `substr` happily returns `AC` — so a region appears in the report that does not exist in the business.
The second failure is change. Re-badge a store from mall to flagship, or move it between reporting regions, and either the key changes — breaking every fact that references it — or the key keeps lying. A smart key makes an attribute permanent by accident, which is a decision nobody made.
The corrected model keeps the code as a natural key, because it is how humans and source systems refer to the store, and promotes region and format to real columns. They can now be corrected, changed, given Type 2 history if the business needs it, and validated against a known list.
This is the same reasoning as the surrogate-key questions, from another direction: keys should identify, attributes should describe, and any design that merges the two makes both harder to change. The tell in a review is a report containing `substr`, `left(`, `split_part` or a regex applied to a key column.
The fix verified against SQLite
The model, corrected
The code is kept as a natural key; what it encoded is now columns.
dim_storedimension
store_skintPK
store_codetextNK
regiontext
store_formattext
openeddate
one row per store
4 seeded rows
fct_salesfact
store_skintFK
amountnummeasure
one row per sale
4 seeded rows
fct_sales.store_sk→ many-to-onedim_store.store_skattributes are columns, so they can change without touching the key
The same question, asked again
SELECT d.region, SUM(f.amount) AS revenue
FROM fct_sales f JOIN dim_store d ON d.store_sk = f.store_sk
GROUP BY d.region ORDER BY d.region;
Returns
region
revenue
EU
160
US
65
The answer most people give
"Add a check constraint so every code matches the pattern." That rejects the acquired store rather than modelling it, and the business will still want its sales counted. The scheme is the problem, not the exception.
They’ll ask next
Regions are re-drawn and three stores move. What has to change in each model?
`margin_pct` is stored per order. The company-wide margin on the dashboard is 35%. The real figure is 20.3%. What is the model doing wrong?
The model as built
Margin percentage precomputed per order and stored on the fact.
fct_orderfact
order_idtextDD
revenuenummeasure
costnummeasure
margin_pctnummeasurea ratio stored as if it were additive
one row per order
2 seeded rows
The query the business runs
SELECT AVG(margin_pct) AS margin_pct_averaged FROM fct_order;
It returns
margin_pct_averaged
35
Why they ask this
Storing a ratio is a modelling decision to be wrong at every grain above the one it was computed at, and averaging averages is how it surfaces.
Say this
Averaging a per-order percentage weights a 10 order the same as a 1000 one. Store the numerator and the denominator — margin and revenue — and compute `SUM(margin) / SUM(revenue)` at whatever grain is asked for.
The reasoning
A percentage is only meaningful with its denominator attached, and a fact table stores the value without it. `AVG(margin_pct)` treats every order as equally important, so the tiny 50%-margin order pulls the company figure from 20.3% to 35%. The larger the spread in order sizes, the worse it gets.
This is non-additivity in its purest form: the measure cannot be summed *or* averaged correctly across any dimension. That makes it different from the semi-additive case, where a rule about time is enough — here there is no aggregation of the stored column that gives the right answer at a coarser grain.
The corrected model stores `margin` alongside `revenue`, both additive, and derives the ratio at query time. That is correct at every grain — by order, by region, by month, company-wide — with no rule for anyone to remember, because the arithmetic is done after the aggregation rather than before.
The rule to state: never store a ratio in a fact table, store its components. Where a ratio must be presented consistently, define it once in the semantic layer as a derived metric over the two sums, so every tool computes it the same way and nobody can average it by mistake.
The fix verified against SQLite
The model, corrected
Numerator and denominator stored; the ratio is computed at query time.
fct_orderfact
order_idtextDD
revenuenummeasure
costnummeasure
marginnummeasurenumerator, additive
one row per order
2 seeded rows
The same question, asked again
SELECT ROUND(100.0 * SUM(margin) / SUM(revenue), 2) AS margin_pct FROM fct_order;
Returns
margin_pct
20.3
The answer most people give
"Use a weighted average, weighting by revenue." That gives the right number and requires every analyst to know the weighting column and apply it. `SUM(margin) / SUM(revenue)` is the same arithmetic with nothing to remember.
They’ll ask next
Which other stored values are secretly ratios? What about `avg_order_value` on a daily aggregate?
The fact has `amount` and `currency`. Total revenue reads 10,200. What is that number?
The model as built
One amount column holding three currencies. Nothing stops it being summed.
fct_orderfact
order_idtextDD
currencytext
amountnummeasurein whatever currency the order was placed
one row per order
3 seeded rows
The query the business runs
SELECT SUM(amount) AS total_of_three_currencies FROM fct_order;
It returns
total_of_three_currencies
10200
Why they ask this
It is a nonsense figure produced by a completely ordinary query, and the fix — store both local and a common currency — has a subtlety about which exchange rate to use.
Say this
Nothing. It is euros plus dollars plus yen added together. Store the transacted amount for audit *and* a converted amount in a common currency, using the rate on the transaction date, and aggregate the converted column.
The reasoning
The column is not a measure, it is three measures sharing a name. Because they are all numeric, SQL sums them without complaint and produces a figure with no unit — dominated here by the yen order, which contributes 10,000 of the 10,200.
The model has no way to stop this. Any consumer who does not think to group by currency gets a meaningless total, and grouping by currency is not what the business wants either — 'revenue' is one number, not three.
The corrected model keeps `amount_local` with its currency for audit and reconciliation, and adds `amount_eur` converted at the rate on the order date. Aggregation uses the converted column, and the local amount is still there to prove what was actually transacted.
The subtlety worth raising unprompted is *which* rate. Converting at the transaction date freezes revenue as it was earned, so history never moves — which is what finance normally wants. Converting at a reporting-period rate makes periods comparable but restates the past every time rates move. Both are used; the failure is not choosing, or choosing per-report. Store the rate you used on the fact so the conversion is auditable.
The fix verified against SQLite
The model, corrected
Local amount kept for audit; a common-currency amount added for aggregation.
fct_orderfact
order_idtextDD
currencytext
amount_localnummeasureas transacted
amount_eurnummeasureconverted at the rate on the order date
one row per order
3 seeded rows
The same question, asked again
SELECT SUM(amount_eur) AS total_eur FROM fct_order;
Returns
total_eur
254
The answer most people give
"Convert at query time by joining an exchange-rate table." That works and makes every historical number depend on which rate row the join picked — including a rate that arrived after the order. Freeze the conversion at load, and keep the rate.
They’ll ask next
A currency is re-denominated and old amounts are restated. Which of your two columns changes?
Semantic layer & metricsOne Big Table & wide tables
Finance reports revenue of 150 and marketing reports 160, from two fact tables at the same grain, both called revenue. Which is right, and what would you change?
The model as built
Two teams built a revenue fact. Both are called revenue.
fct_orders_financefact
order_idtextDD
revenuenummeasurenet of returns
one row per order
2 seeded rows
fct_orders_marketingfact
order_idtextDD
revenuenummeasuregross, returns ignored
one row per order
2 seeded rows
fct_orders_marketing.order_id→ one-to-onefct_orders_finance.order_idsame grain, same name, two different numbers and no way to tell which is meant
The query the business runs
SELECT (SELECT SUM(revenue) FROM fct_orders_finance) AS finance_says,
(SELECT SUM(revenue) FROM fct_orders_marketing) AS marketing_says;
It returns
finance_says
marketing_says
150
160
Why they ask this
It is the organisational failure mode of a warehouse rather than a technical one, and the good answer is about where a definition lives rather than about which team is correct.
Say this
Both are right and neither is labelled. One is net of returns and one is gross, and nothing in either model says so. Build one fact holding both components and define the two metrics once, by name, on top of it.
The reasoning
Two tables at the same grain with the same measure name and different values is a definition problem wearing a modelling costume. Neither number is wrong — net revenue really is 150 and gross really is 160 — but the model gives a consumer no way to know which they have, so the disagreement surfaces as a trust problem in a meeting.
Duplicating the fact also duplicates the maintenance: two loads, two sets of tests, two places for the grain to drift. When they eventually diverge for a genuine reason — one picks up a new order source — nobody can tell whether the gap is the definition or a bug.
The corrected model stores the components — gross revenue and returns — on a single fact at order grain, and derives both metrics from it. `net_revenue` and `gross_revenue` become named definitions rather than table names, so a consumer chooses explicitly and both are guaranteed consistent because they come from the same rows.
Where those definitions live matters as much as the fact. A semantic layer or metrics store gives you one place where `net_revenue = gross_revenue - returns` is written down, versioned and reviewed — which is what stops a third definition appearing the next time a team needs a number quickly.
The fix verified against SQLite
The model, corrected
One fact, both components stored, and the two metrics defined once on top of it.
fct_orderfact
order_idtextDD
gross_revenuenummeasure
returnsnummeasure
one row per order
2 seeded rows
The same question, asked again
SELECT SUM(gross_revenue) - SUM(returns) AS net_revenue,
SUM(gross_revenue) AS gross_revenue
FROM fct_order;
Returns
net_revenue
gross_revenue
150
160
The answer most people give
"Pick finance’s definition and deprecate the other." Marketing needs gross revenue for legitimate reasons, and deleting their table just moves the second definition into a spreadsheet where nobody can see it.
They’ll ask next
Where would you write down that `net_revenue = gross - returns`, and who reviews a change to it?
A daily aggregate stores `customers` as a distinct count per day per region. Rolling it up to a month gives 4 customers; there are 3. Why?
The model as built
A pre-aggregate storing a distinct count, which reports then add up.
agg_daily_regionfact
daydateFK
regiontextFK
ordersintmeasure
customersintmeasureCOUNT(DISTINCT customer) per row
one row per day per region
3 seeded rows
The query the business runs
SELECT SUM(customers) AS customers_rolled_up FROM agg_daily_region;
It returns
customers_rolled_up
4
Why they ask this
Pre-aggregation is the standard performance answer and distinct counts are the one measure it cannot store, which catches people who have only ever built additive cubes.
Say this
Distinct counts are not additive. A customer active on two days is counted in both rows, so summing the days double-counts them. Distinct counts must be computed from the atomic fact at the grain being asked for, or approximated with a sketch.
The reasoning
Additive measures survive pre-aggregation because a sum of sums is a sum. A distinct count has no such property: knowing 2 distinct customers on Monday and 1 on Tuesday tells you nothing about the distinct total, because you cannot tell whether they overlap. Here C1 appears on both days, so 2 + 1 + 1 gives 4 for 3 real customers.
It is not fixable by storing a different aggregate. Any pre-aggregate at day-region grain has already discarded the identities it would need, and the error is not a constant — it depends entirely on how much the population overlaps between the rows being combined, so it cannot even be corrected after the fact.
The corrected model computes the distinct count from the atomic fact, which retains `customer_id` and therefore answers the question at any grain. That is slower, and it is the only exact option.
Where the exact query is too slow, there are two real answers. Store a sketch — HyperLogLog — which *is* mergeable, so per-day sketches can be unioned to give an approximate monthly figure within a percent or so; most warehouses have native support. Or pre-compute the specific roll-ups the business asks for, each with its own distinct count, accepting that a new grain needs a new aggregate. What you must not do is store a distinct count and let people add it up.
The fix verified against SQLite
The model, corrected
The distinct count is computed from the atomic fact, at whatever grain is asked for.
fct_orderfact
order_idtextDD
daydateFK
regiontextFK
customer_idtextFK
amountnummeasure
one row per order
4 seeded rows
The same question, asked again
SELECT COUNT(DISTINCT customer_id) AS distinct_customers FROM fct_order;
Returns
distinct_customers
3
The answer most people give
"Take the maximum daily figure instead of the sum." That is a lower bound, not the answer — it assumes nobody new appeared on any other day, which is false whenever the population changes at all.
They’ll ask next
Which measures *can* safely live in a pre-aggregate? What is the test?
Someone hands you a schema you have never seen and asks whether it is any good. What do you actually do, in order?
Why they ask this
It is the flat form of the whole critique round, and it separates candidates who have a method from candidates who will notice whatever happens to catch their eye first.
Say this
Ask what questions it has to answer, then read the grain of every table, then trace one required question through the joins looking for fan-out, then check what happens when something changes. Structure before opinions: a schema is only good or bad relative to what it is for.
The reasoning
**1 · Ask what it is for.** A schema cannot be reviewed in the abstract. Get three questions it must answer and the volume it runs at. Half the "problems" in an unfamiliar model are deliberate trade-offs for a workload you have not been told about, and leading with those makes the rest of your review sound guessed.
**2 · Read the grain of every table, out loud.** "One row per ___" for each one. The tables where you cannot finish the sentence are where the defects are, in my experience almost without exception. A table whose grain nobody can state is a table whose sums nobody can trust.
**3 · Trace one required question through the joins.** Follow it table by table and watch for a **fan-out** — a join from a coarse grain to a finer one that multiplies rows before an aggregate. This is the highest-yield single check in a review, because it produces wrong numbers with no error, so it survives testing.
**4 · Ask what changes and what must not.** Which values move over time, and would a change reach the past? A price on a product with no history, referenced by an old order line, means every historical order silently re-prices. That is one column and it is usually the most expensive defect in the diagram.
**5 · Then look at keys and constraints.** What is unique, what is mandatory, and which business rule is being enforced by hope rather than by the database. Only now, because a constraint on the wrong grain is not worth discussing.
The answer most people give
Opening with normal forms. Telling someone their schema is not in third normal form is both usually true and almost never the thing that is hurting them, and it signals that you are checking the model against a textbook rather than against its job.
They’ll ask next
You have found a fan-out. How do you demonstrate to the owner that it is real, rather than just asserting it?
What are the data modelling mistakes you see most often, and what does each one actually cost?
Why they ask this
It is an easy question to answer thinly and a very revealing one to answer well: naming the consequence rather than the mistake is what shows you have lived with the results rather than read a list.
Say this
An undeclared grain, a join that fans out, a mutable attribute with no history, and a business rule enforced in application code instead of by a constraint. All four share a property — they produce wrong data silently, so nothing fails and somebody reports the number.
The reasoning
**Undeclared grain.** Nobody wrote down what one row means, so two engineers write two correct-looking queries that return different totals. **Cost:** an argument nobody can settle from the data, usually discovered in a meeting rather than in a test.
**Fan-out on a join.** Joining a coarse table to a finer one multiplies rows before the aggregate. **Cost:** inflated revenue with no error message. This is the one I look for first in any unfamiliar model, because it survives every layer of testing that checks for exceptions rather than for values.
**A mutable attribute with no history.** A price, an address or a plan updated in place, referenced by rows that were written when it was different. **Cost:** the past changes. Last quarter’s report re-run today no longer matches the copy in the board pack, and no one can say which is right.
**A rule enforced in the application rather than by a constraint.** "We check before inserting." Two requests pass the check in the same millisecond and both write. **Cost:** the double booking, the duplicate order, the second payment — and the check is not wrong, it is just not a constraint, which is a distinction the failure teaches expensively.
**The thread through all four:** each produces *wrong data* rather than *an error*. Nothing crashes, nothing alerts, and the defect is found by a person who did not believe a number. That is why a model is reviewed against the questions it must answer rather than against a style guide.
The answer most people give
Listing "poor naming conventions" and "not normalising enough". Both are real and neither costs anything comparable — reaching for them suggests you have reviewed diagrams but not lived with what a bad one does to a report six months later.
They’ll ask next
Pick the one you have personally caused, and tell me what you changed about how you work afterwards.