A business described in a paragraph; produce the model. The work is in the questions asked before drawing anything — what one row means, which facts are additive, and where history has to be kept. Eight of these carry a model that was built and queried; the rest carry the candidate designs and the verdict.
Before any table exists: what happened, and what does one row of it mean?
Which fact, which measures
4
Events, states and durations need different tables. So do measures that cannot be added.
Identity & keys
2
What makes a row unique, across one system and across several.
Dimensions & relationships
5
Where attributes live, and what to do when one thing belongs to many.
Questions to ask before drawing
5
The part of the interview that happens before the whiteboard.
Evergreen · asked verbatim
2
The flat form, in the words interviewers actually use — including the domain prompts that arrive as one sentence: “design a ledger”, “design a model for wallet transactions”.
A SaaS business wants MRR, net new MRR, churn and expansion. Describe the model you would build and the grain of each table.
Why they ask this
It is the classic case where one fact cannot answer the questions asked, and the good answer builds two tables over one business process without being prompted.
Say this
Two facts. A transaction fact at one row per subscription change event, which answers net new, expansion and churn by summing MRR deltas; and a monthly snapshot at one row per subscription per month, which answers `what is MRR` directly and has a row for months where nothing happened.
The reasoning
Start from the questions. Net new, expansion and churn are all *changes*, so they want an event grain — one row per subscription change, with a signed MRR delta. Summing that column over any period gives the net movement, and splitting by event type gives the components.
MRR itself is a *state*, and deriving it from events means summing every delta since the beginning of time for every subscription. A monthly snapshot answers it in one filter, and — more importantly — it has a row for a subscription that did not change this month, which the event fact does not. That is what makes 'MRR by month' and 'accounts with no activity' straightforward.
The snapshot measure is semi-additive: it sums across subscriptions and must not be summed across months. That belongs in the model as an annotation and in the semantic layer as a metric definition, because a raw column named `mrr` will otherwise be summed by someone.
The verification below is the reconciliation you would build as a test: the events sum to 150 of net change, and the March snapshot shows MRR of 150. When those two disagree, one of the two loads is wrong, and having both makes that detectable.
The formulations
Event fact + monthly snapshotship
fct_subscription_event -- one row per change
fct_subscription_month -- one row per subscription per month
Changes and states are different questions; each gets the grain that answers it directly.
Event fact onlyworks
fct_subscription_event -- derive MRR by summing all history
Correct and expensive, and it has no row for a quiet month, so gaps are invisible.
Snapshot onlyavoid
fct_subscription_month -- infer churn by comparing months
Loses the reason for every change: an upgrade and a downgrade that net to zero disappear.
A dimension pretending to be a fact; no history at all, so every trend question is unanswerable.
See it verified against SQLite
The model
Two facts over one process: the events that changed MRR, and the state each month.
fct_subscription_eventfact
event_idintPK
subscription_idtextDD
event_daydateFK
event_typetext
mrr_deltanummeasure
one row per subscription change event
4 seeded rows
fct_subscription_monthfact
subscription_idtextDD
monthdateFK
mrrnummeasuresemi-additive
one row per subscription per month (periodic snapshot)
6 seeded rows
SELECT 'from events: net change' AS answers, SUM(mrr_delta) AS value
FROM fct_subscription_event
UNION ALL
SELECT 'from snapshot: MRR in March', SUM(mrr)
FROM fct_subscription_month WHERE month = '2026-03-01';
answers
value
from events: net change
150
from snapshot: MRR in March
150
The answer most people give
"Store MRR on the customer dimension and update it." That is a Type 1 overwrite of the number the whole business is measured on, so every historical MRR figure changes whenever a subscription does.
They’ll ask next
A subscription upgrades and downgrades in the same month. What does each of your two tables show?
A retailer wants average basket value, attach rate and revenue by product. Do you model at basket grain or line grain?
Why they ask this
Both are defensible and only one answers all three questions, so it tests whether you pick the grain from the questions rather than from the source file.
Say this
Line grain. Product revenue is impossible at basket grain, and every basket-level metric is still available by aggregating up — 2 baskets, 3 lines, an average basket value of 30. Going the other way is not possible.
The reasoning
The rule is to model at the finest grain the business process produces, unless there is a specific reason not to. A finer grain can always be rolled up; a coarser one has thrown information away permanently, and the request for product-level revenue arrives about a week after you ship.
The metrics all survive. Baskets are `COUNT(DISTINCT basket_id)`, attach rate is lines per basket, and average basket value is total revenue over distinct baskets — the query below computes all three from the line fact. The only cost is that basket-level questions need a `DISTINCT`, which is a small price for keeping the option.
Basket-level measures that genuinely exist at that grain — a basket-wide discount, delivery fee, or the payment method — go in an order-grain fact or a dimension, not onto the line rows. That is the mixed-grain trap, and this is the design-time moment to avoid it.
The reasons to go coarser are real but specific: extreme volume where line detail is unaffordable, or a privacy constraint that forbids item-level retention. Both are decisions to state out loud, with the cost named, rather than defaults.
Answers product questions and every basket question; the default unless something forbids it.
Basket grainworks
fct_basket(basket_id, n_lines, total_amount)
Cheaper and smaller, and product-level analysis is gone for good.
Bothworks
fct_basket_line + agg_basket -- a derived aggregate
Reasonable when basket queries are hot; the aggregate must be derived, never loaded separately.
See it verified against SQLite
The model
Line grain keeps product analysis possible; basket-level questions aggregate up.
fct_basket_linefact
basket_idtextDD
line_noint
skutextFK
qtyintmeasure
extended_amountnummeasure
one row per line on a basket
3 seeded rows
SELECT COUNT(DISTINCT basket_id) AS baskets,
COUNT(*) AS lines,
ROUND(COUNT(*) * 1.0 / COUNT(DISTINCT basket_id), 2) AS avg_lines_per_basket,
ROUND(SUM(extended_amount) * 1.0 / COUNT(DISTINCT basket_id), 2) AS avg_basket_value
FROM fct_basket_line;
baskets
lines
avg_lines_per_basket
avg_basket_value
2
3
1.5
30
The answer most people give
"Whatever grain the source file arrives in." The source decides what is available, not what the model should be. A file at line grain aggregated to baskets on load is a decision that cannot be undone.
They’ll ask next
Where would you put a basket-wide 10% promotion, and what does that do to product revenue?
Given a business process, how do you decide whether to model the events or the state it produces?
Why they ask this
It is the decision underneath the three fact types, and framing it well means you can derive the right answer instead of memorising which table to build.
Say this
Ask what the questions sum. If they sum things that happened — revenue, quantity, clicks — model the events. If they ask what was true at a point in time — balance, headcount, stock — model the state. Most real processes need both, and they reconcile to each other.
The reasoning
Events are additive and cheap: a row per thing that happened, insert-only, growing with activity. Any 'how much' question is a `SUM` and any period is a filter. What they cannot answer cheaply is 'what was it on the 14th', which requires replaying every event from the start of time.
State is a snapshot: a row per entity per period, whether or not anything happened. That last part is what makes it worth its size — questions about inactivity, averages over a population, and point-in-time balances all need the rows where nothing occurred, and an event table simply has none.
The tell for needing both is a business that asks 'how much changed' and 'what is it now' in the same conversation. Build the event fact as the source of truth, derive the snapshot from it, and reconcile: the snapshot at period end should equal the opening balance plus the events in between. That reconciliation is one of the most valuable tests in the warehouse.
A third case is worth naming: when the question is 'how long between steps', neither shape is convenient, and an accumulating snapshot — one row per process instance with a column per milestone, updated in place — answers it directly.
The formulations
Event fact, snapshot derived from itship
fct_payment -- one row per event
fct_daily_balance -- derived nightly from the events
One source of truth, and the reconciliation between them is a test you can run.
The right shape when the question is duration between a known set of milestones.
Events onlyworks
fct_payment -- replay from the beginning for any balance
Correct and expensive, and it has no row for an entity that did nothing.
Snapshot onlyavoid
fct_daily_balance -- loaded directly from the source
Loses every reason a value changed, and two offsetting movements become invisible.
The answer most people give
"Model the events, since you can always derive the state." True and incomplete. Derivation is expensive and, more importantly, an event table has no row for an entity that did nothing — so questions about inactivity cannot be asked at all.
They’ll ask next
Your snapshot and your event fact disagree by 3 units. Which do you trust, and what does the reconciliation test look like?
You need to report which students were absent. Absence is not an event — nothing happened. How do you model it?
Why they ask this
Coverage questions — what did *not* happen — need two factless facts, and it is a shape most candidates have never had to reach for.
Say this
Two factless facts: one row per attendance, and one row per enrolment. Absence is the enrolment rows with no matching attendance row. Counting rows is the measure; there is no numeric column at all.
The reasoning
A factless fact records that a combination of dimensions occurred. Attendance is exactly that: student, class, day, and nothing to add up. `COUNT(*)` is the measure, and grouping by any dimension gives attendance counts without a numeric column existing anywhere.
That alone cannot answer the absence question, because a row that does not exist cannot be counted. The second factless fact — coverage — records what *should* have happened: one row per student enrolled in a class. Absence is then the difference between the two, which the query below computes as 2 enrolled and 1 attended in physics, so one absence.
The pattern generalises well beyond attendance: promotions that ran against products that did not sell, eligible customers who did not claim, contracted SLAs against tickets that met them. In every case the coverage fact enumerates the possible, the event fact records the actual, and the interesting number is the gap.
The design decision worth flagging is the grain of the coverage fact. Enrolment per student per class gives absence per class; enrolment per student per class per scheduled day gives absence per session, which is bigger and usually what is actually wanted. Choosing that grain is the real work.
See it verified against SQLite
The model
A fact with no measures. Counting rows is the measure; the second one records what did not happen.
dim_studentdimension
student_skintPK
studenttext
one row per student
2 seeded rows
dim_classdimension
class_skintPK
classtext
one row per class
2 seeded rows
fct_attendancefact
student_skintFK
class_skintFK
daydateFK
one row per student per class per day attended
3 seeded rows
fct_enrolmentfact
student_skintFK
class_skintFK
one row per student enrolled in a class (coverage)
SELECT c.class,
(SELECT COUNT(*) FROM fct_enrolment e WHERE e.class_sk = c.class_sk) AS enrolled,
(SELECT COUNT(*) FROM fct_attendance a WHERE a.class_sk = c.class_sk) AS attended,
(SELECT COUNT(*) FROM fct_enrolment e WHERE e.class_sk = c.class_sk)
- (SELECT COUNT(*) FROM fct_attendance a WHERE a.class_sk = c.class_sk) AS absences
FROM dim_class c ORDER BY c.class;
class
enrolled
attended
absences
maths
2
2
0
physics
2
1
1
The answer most people give
"Add an `attended` flag with 0 and 1 to one table." That requires a row for every non-attendance, which is the coverage fact you were avoiding — you have built it and called it a flag, and now the grain is implicit.
They’ll ask next
Absence per class or absence per scheduled session? What does each grain cost, and which does the school actually want?
Operations want average time from order to ship, and how many orders are stuck at each stage. What do you build?
Why they ask this
It is the case an accumulating snapshot exists for, and reaching for an event fact instead makes every duration question a self-join.
Say this
An accumulating snapshot: one row per order, with a date column per milestone, updated in place as the order progresses. Durations become subtractions and 'stuck at picking' is a NULL check — both trivial, where an event table needs a self-join per pair of stages.
The reasoning
The process has a known, fixed set of milestones, which is the precondition for this shape. One row per order carries `ordered_day`, `picked_day`, `shipped_day` and `delivered_day`, and the row is updated as each happens. Days-to-ship is one subtraction; orders in flight are rows with a NULL delivery date, as the query shows for SO-2.
The alternative — an event fact with one row per status change — is the more natural instinct and makes every duration a self-join between two filtered copies of the table, per pair of stages. It also makes 'how many are stuck' a correlated not-exists rather than a NULL test. Both are writable and neither is pleasant at scale.
The cost is the update. Warehouses handle it, and append-only lakehouse tables do not — so on Iceberg or Delta this becomes a MERGE, and on plain Parquet it becomes a partition rewrite. That is worth raising unprompted, because it is the reason some teams avoid the shape and rebuild it daily from the event fact instead, which is also a legitimate answer.
In practice you often keep both: the event fact as the immutable record of what happened, and the accumulating snapshot derived from it as a serving table. That gives auditability and convenient duration queries, at the cost of a rebuild.
See it verified against SQLite
The model
An accumulating snapshot: one row per process instance, with a column per milestone.
fct_order_fulfilmentfact
order_idtextPK
ordered_daydateFK
picked_daydateFK
shipped_daydateFK
delivered_daydateFK
amountnummeasure
one row per order, updated as it moves through the pipeline
2 seeded rows
SELECT order_id,
julianday(shipped_day) - julianday(ordered_day) AS days_to_ship,
CASE WHEN delivered_day IS NULL THEN 'in flight' ELSE 'delivered' END AS status
FROM fct_order_fulfilment ORDER BY order_id;
order_id
days_to_ship
status
SO-1
2
delivered
SO-2
3
in flight
The answer most people give
"Use a periodic snapshot — one row per order per day." That grows by orders times days for a process that only changes four times, and it still needs a self-join to find when each milestone happened.
They’ll ask next
Your storage layer is append-only Parquet. How do you maintain this table, and what changes?
Shipping of 10 must be reported per line, across three lines of 100 each. How do you allocate it, and what do you do with the remainder?
Why they ask this
Allocation is where a modelling decision becomes a business decision, and the rounding question is the one people have not thought about until asked.
Say this
Allocate in proportion to line amount and store the allocated value on the fact. Ten across three equal lines is 3.33 each with 0.01 left over — use largest remainder so the parts sum to the whole exactly, giving 3.33, 3.33 and 3.34.
The reasoning
Allocation makes a coarse measure additive at the finer grain, which is what lets a plain `SUM` be correct at every level. The alternative — leaving it at order grain and drilling across — is often better and is not always available, because the business may genuinely want shipping cost attributed per product line.
The arithmetic will not divide evenly, and that is the part to have an answer for. Naive rounding gives three lines of 3.33 totalling 9.99, so the allocated total no longer ties to the order total and the discrepancy grows with volume. Largest remainder assigns the leftover cent to the line with the largest fractional part, so the parts always sum to the whole — the query below shows a difference of exactly 0.
The proportion is a business rule, not a technical one. By line amount, by quantity, by weight, by volume — each gives a different answer and each is defensible for a different business. Name it in the column (`allocated_shipping_by_amount`) so a reader can see which was used, and get it agreed rather than chosen by whoever wrote the load.
Store the allocated figure rather than computing it in the query. Recomputing means every consumer must know the rule, and any change to it silently restates history — the same argument as storing the price charged rather than deriving it from a price list.
See it verified against SQLite
The model
Shipping of 10 across three equal lines. The remainder has to go somewhere.
fct_orderfact
order_idtextPK
shippingnummeasure
one row per order
1 seeded row
fct_order_linefact
order_idtextDD
line_noint
line_amountnummeasure
allocated_shippingnummeasurelargest remainder, so the parts sum to the whole
SELECT (SELECT SUM(shipping) FROM fct_order) AS shipping_at_order_grain,
(SELECT SUM(allocated_shipping) FROM fct_order_line) AS allocated_total,
(SELECT SUM(shipping) FROM fct_order)
- (SELECT SUM(allocated_shipping) FROM fct_order_line) AS difference;
shipping_at_order_grain
allocated_total
difference
10
10
0
The answer most people give
"Divide by the line count and round." That over- or under-states the total by up to a cent per order, which reconciliation will find, and it ignores line size — a 1000 line and a 10 line carry equal shipping.
They’ll ask next
A line is cancelled after allocation. Do you re-allocate across the survivors, and what does that do to a published report?
You have drafted a fact table with eight measures. What is the check you run over each one before you ship it?
Why they ask this
It is a mechanical discipline that prevents a whole class of silent reporting errors, and very few candidates have a routine for it.
Say this
For each measure, ask whether summing it across every dimension gives a meaningful number. Fully additive ones need nothing. Semi-additive ones — balances, stock, headcount — need a stated time rule. Non-additive ones — ratios, percentages, unit prices — must be split into numerator and denominator.
The reasoning
Take the dimensions of the fact one at a time and ask whether a `SUM` over that dimension means anything. Revenue across customers, products and days: yes on all three, so it is additive and needs no rule. Account balance across accounts: yes. Across days: no — and that single 'no' makes it semi-additive.
Semi-additive measures need the replacement operation stated at design time: last value, first value, or average over the period. Where it lives matters — a column note is a start, a semantic-layer metric definition is the real answer, because it is the only place that every consumer inherits.
Non-additive measures should not be in the fact at all. A margin percentage cannot be summed *or* averaged correctly above the grain it was computed at, so store `margin` and `revenue` and derive the ratio after aggregation. The same applies to unit price, conversion rate and any per-something figure.
The output of the audit is worth keeping: a table of measure, additivity class, and the rule if it has one. It becomes the specification for the semantic layer, and it is the artefact that stops the next person adding `avg_order_value` as a stored column.
The formulations
Numerator and denominatorship
revenue NUMERIC,
margin NUMERIC -- ratio derived: SUM(margin)/SUM(revenue)
Correct at every grain with no rule to remember, because the division happens after aggregation.
Semi-additive with a stated ruleship
balance NUMERIC -- semi-additive: LAST_VALUE over time
Fine when the rule is in the semantic layer rather than in each analyst's head.
Stored ratioavoid
margin_pct NUMERIC -- 20.0
Wrong at every grain above the one it was computed at, and averaging it weights all rows equally.
The answer most people give
"Test the measures against last month's report." That confirms the current queries agree with themselves. Additivity is a property of the measure, and the check has to be per dimension rather than per report.
They’ll ask next
Is `days_to_ship` additive? What about `discount_pct`? What would you store instead?
The CRM and the billing system both have customer records for the same people, with different ids. Model it.
Why they ask this
Multi-source integration is where surrogate keys stop being a convention and start being load-bearing, and the answer has to keep both the source record and the resolved person.
Say this
A surrogate key per *source record*, plus a `master_id` that resolves records believed to be the same person. Facts point at the source record; reports group by master id. That keeps lineage intact and makes the matching reversible when it turns out to be wrong.
The reasoning
Two ids for one person is an identity-resolution problem, and the temptation is to merge the records on load. Do that and you lose the ability to trace a warehouse row back to the system it came from, and — more painfully — the ability to undo a match that turns out to be two different people who shared an email.
Keeping one dimension row per source record and adding a `master_id` gives you both. Facts continue to reference the record they actually came from, so lineage is exact; reports group by `master_id`, so a person is counted once. The query shows M-1 with two orders and 160 of revenue across two source systems.
The matching itself is a separate concern with its own machinery — deterministic rules on email or tax id, probabilistic scoring, or a full MDM tool — and it belongs outside the model. What the model owes it is a stable place to record the outcome and the confidence, so that a match can be reviewed and reversed.
The failure to avoid is letting one system win. Picking the CRM id as the key means the billing system's records are either dropped or forced into an id space they do not belong to, and every fact from billing then depends on a matching step having succeeded.
Lineage kept, matching reversible, and reports still see one customer.
One row per resolved personworks
dim_customer(customer_sk PK, master_id NK, ...) -- merged on load
Simpler to query and it discards which system said what, so a bad match cannot be undone.
Use the CRM id as the keyavoid
dim_customer(crm_id PK, ...)
Billing records have no crm_id, so they are dropped or invented; one system's outage becomes a data loss.
See it verified against SQLite
The model
Two systems, one person. The surrogate identifies the source record; master_id identifies the human.
dim_customerdimension
customer_skintPK
master_idtextNK
source_systemtext
source_idtextNK
emailtext
one row per source record, mapped to a master id
3 seeded rows
fct_orderfact
order_idtextDD
customer_skintFK
amountnummeasure
one row per order
3 seeded rows
fct_order.customer_sk→ many-to-onedim_customer.customer_skfacts point at the source record; roll up by master_id to see one customer
SELECT d.master_id, COUNT(*) AS orders, SUM(f.amount) AS revenue
FROM fct_order f JOIN dim_customer d ON d.customer_sk = f.customer_sk
GROUP BY d.master_id ORDER BY d.master_id;
master_id
orders
revenue
M-1
2
160
M-2
1
40
The answer most people give
"Concatenate the system name and the id to make a key." That gives a unique key and does nothing about identity — the same person still appears twice in every count, which was the actual problem.
They’ll ask next
Two records were matched and turn out to be different people. What has to change, and which facts move?
Does a fact table need a primary key? What would you use, and what does it buy you?
Why they ask this
Many warehouses ship facts with no key at all. The answer reveals whether the candidate treats the grain as an enforceable contract or a comment.
Say this
Yes — the declared grain *is* the key, expressed as columns: order id plus line number, or the dimension keys plus the date. It buys idempotent loads, a duplicate check that can run in the pipeline, and a way to do targeted corrections.
The reasoning
The grain sentence translates directly into a candidate key. 'One row per line on an order' means (order_id, line_no) must be unique; 'one row per account per day' means (account_sk, day_key). Writing that down turns the grain from documentation into something a test can assert, which is the difference between believing the grain holds and knowing it.
The practical payoff is idempotent loading. With a key you can MERGE on it, so a re-run replaces rather than appends and a retry after a partial failure is safe. Without one, re-running the load is how a fact table doubles, which is the single most common cause of an overnight revenue jump.
A surrogate key on the fact — a single generated integer — is a separate and lesser thing. It makes individual rows addressable, which is useful for corrections and for referencing a specific row in a support conversation, and it enforces nothing about the grain because it is unique by construction. Have both if you like, but the composite key is the one doing the work.
Where a natural composite key genuinely does not exist — a clickstream with no event id and no distinguishing attributes — that is a finding rather than a licence to skip the key. Either the source should supply an event id, or you need a deterministic hash of the payload, and either way the ambiguity should be raised rather than absorbed.
The formulations
The declared grain, as a composite keyship
PRIMARY KEY (order_id, line_no) -- the grain sentence, enforced
Turns the grain from a comment into something a MERGE and a test can both rely on.
Dimension keys plus the dateship
PRIMARY KEY (account_sk, day_key) -- for a periodic snapshot
The same rule where there is no natural transaction id; the grain still names the columns.
A generated surrogate on the factworks
order_line_sk INTEGER PRIMARY KEY -- unique by construction
Useful for addressing a single row, and it enforces nothing about the grain.
No key at allavoid
-- append-only, no constraint
Nothing stops the same batch loading twice, and nothing can tell you that it did.
The answer most people give
"Facts do not need keys — they are append-only." Append-only is exactly why they do: nothing stops the same batch being appended twice, and without a key nothing can tell that it was.
They’ll ask next
Your clickstream has no natural key at all. What do you do, and what does that make possible?
The order number sits on the fact as a degenerate dimension. The business now wants to slice by channel and gift flag. What changes?
Why they ask this
It tests the rule rather than the label: a dimension exists to hold attributes, so the moment attributes appear, the answer changes.
Say this
It becomes a real dimension. A degenerate dimension is an id with nothing to describe it; once channel and gift flag exist, a `dim_order` has something to hold and the fact carries `order_sk` instead. Counting orders still works, now as a count of distinct dimension keys.
The reasoning
The test for a dimension table is whether it would have a second column. Before, `dim_order` would have held only `order_id`, so the join would return no information and the id belonged on the fact. Now there are two attributes that reports want to filter and group by, and they have to live somewhere.
Putting them on the fact instead is the tempting shortcut and it repeats them on every line of the order — which is the mixed-grain trap wearing a different hat. Channel is a property of the order, not of the line, so denormalising it downward is only safe if you never sum it, and the flag is exactly the kind of column someone eventually counts.
The migration is small and worth describing precisely: create the dimension keyed on the natural order number, populate it from the existing distinct values, add `order_sk` to the fact, and keep `order_id` on the dimension as the natural key so lineage back to the source survives. Existing queries counting `COUNT(DISTINCT order_id)` keep working until they are moved.
If the attributes were high in number and low in cardinality — half a dozen flags — the alternative is a junk dimension rather than an order dimension. The deciding question is whether the attributes describe the *order* or merely accompany it.
See it verified against SQLite
The model
Once the order has channel and gift-flag attributes, the degenerate dimension becomes a real one.
dim_orderdimension
order_skintPK
order_idtextNK
channeltext
is_giftbool
one row per order — earns a table once it has attributes
SELECT d.channel, COUNT(DISTINCT d.order_sk) AS orders, SUM(f.amount) AS revenue
FROM fct_order_line f JOIN dim_order d ON d.order_sk = f.order_sk
GROUP BY d.channel ORDER BY d.channel;
channel
orders
revenue
store
1
70
web
1
150
The answer most people give
"Add `channel` and `is_gift` to the line fact." They are order attributes stored at line grain, so any count or sum of them multiplies by the number of lines — and someone will count gifts.
They’ll ask next
Six boolean flags arrive instead of two attributes. Dimension, junk dimension, or columns on the fact?
Accounts roll up into a tree of arbitrary depth, and revenue must be reportable at any node including everything beneath it. Model it.
Why they ask this
Ragged hierarchies defeat the flattened-columns approach that works for fixed ones, and the closure-table answer is the one that scales.
Say this
A closure bridge: one row per ancestor-descendant pair, including each node with itself. Joining a fact through it rolls revenue up to any node in one query — Group sees 80, EMEA sees 80, UK sees 30 — without knowing the depth in advance.
The reasoning
Flattening a hierarchy into `level_1 ... level_5` columns works when the depth is fixed and known, which organisational and account hierarchies rarely are. Add a level and every table and every query changes; have a branch that stops at depth two and the lower columns are NULL, which breaks grouping.
The closure table sidesteps depth entirely. For every node, store a row for each node beneath it, plus a row for itself at depth 0. Joining the fact on `descendant_sk` and grouping by `ancestor_sk` then gives the rolled-up total for every node at once, which is exactly what the query below returns.
The costs are worth naming. The bridge is O(nodes × average depth) rows, which is fine for thousands of accounts and needs thought at millions. It must be rebuilt when the hierarchy changes — usually a full rebuild, since a single re-parenting affects every pair below it. And the `depth` column matters: without filtering on it you cannot ask 'direct children only', and joining without care double-counts if you forget that self-rows exist.
The alternative for engines that support it is a recursive CTE against a simple parent-child table, which needs no bridge and re-walks the tree on every query. That is often the right answer for a small hierarchy queried occasionally, and the wrong one for a dashboard hitting it constantly.
See it verified against SQLite
The model
A ragged hierarchy of unknown depth, flattened into a closure bridge.
dim_accountdimension
account_skintPK
accounttext
one row per account
3 seeded rows
br_account_rollupbridge
ancestor_skintFK
descendant_skintFK
depthint
one row per ancestor-descendant pair, including self
6 seeded rows
fct_revenuefact
account_skintFK
amountnummeasure
one row per revenue posting
2 seeded rows
br_account_rollup.descendant_sk→ many-to-manydim_account.account_ska closure table: every node joined to every node beneath it
SELECT a.account AS rolled_up_to, SUM(f.amount) AS revenue
FROM dim_account a
JOIN br_account_rollup b ON b.ancestor_sk = a.account_sk
JOIN fct_revenue f ON f.account_sk = b.descendant_sk
GROUP BY a.account ORDER BY a.account;
rolled_up_to
revenue
EMEA
80
Group
80
UK
30
The answer most people give
"Flatten it into five level columns." It works until someone adds a sixth level or a branch ends early, and both happen — the first breaks every query, the second fills your groupings with NULLs.
They’ll ask next
Someone re-parents a mid-level account. What has to be rebuilt, and what happens to last month's published numbers?
Star vs snowflakeNormalization formsDimension types (conformed, degenerate, junk, role-playing, bridge)
A product hierarchy — category, department, division — is shared by the product, supplier and store dimensions and is re-organised twice a year. Star or snowflake?
Why they ask this
It is the one case where snowflaking still has a genuine argument, so it separates people repeating 'always star' from people who know why the rule exists.
Say this
Snowflake this hierarchy, and expose a flattened view so consumers still see a star. Three dimensions sharing one hierarchy that changes twice a year is exactly the maintenance case snowflaking is for — otherwise the same tree is maintained in three places and will disagree.
The reasoning
The default is a star, and the usual argument for snowflaking — saving storage on a repeated string — has been dead since columnar compression. So the case has to be made on something else, and here it is: the hierarchy is shared and it changes.
Maintained in three denormalised dimensions, a re-organisation is three loads that must agree. They will not, eventually, and the failure is a report where product revenue by division does not match supplier spend by division for reasons nobody can find. One hierarchy table makes that class of disagreement impossible.
The cost is joins and legibility, and it is real: BI tools generate better SQL against a star, and query paths get longer. The resolution is to keep the normalised hierarchy as the maintained object and publish a flattened view over it for consumers — one place to maintain, a star-shaped thing to query. Most mature warehouses do exactly this.
The remaining question is history. If reports need 'revenue by the division it was in at the time', the hierarchy needs Type 2 versioning, and that decision is independent of the star/snowflake one — it just becomes much easier to implement once when the hierarchy is a table of its own.
The formulations
Snowflake + flattened viewship
dim_product -> dim_category -> dim_department
CREATE VIEW dim_product_flat AS SELECT ... -- star-shaped for consumers
One maintained hierarchy, star-shaped access; the answer when a hierarchy is shared and volatile.
Correct and pushes three joins onto every consumer, including BI tools that assume a star.
The answer most people give
"Always star — snowflaking is obsolete." The storage argument is obsolete. The maintenance argument is not, and a hierarchy shared by three dimensions is precisely where it applies.
They’ll ask next
The division re-organisation must not restate last year. What changes in your answer?
A customer dimension has twenty attributes. How do you decide which need Type 2?
Why they ask this
Applying Type 2 to a whole dimension is the lazy answer and it multiplies the table by the change rate of its most volatile column. The decision is per attribute.
Say this
Per attribute, not per dimension. Ask whether a report would ever group by it *historically*. Region, segment and sales territory almost always qualify; a corrected spelling never does; a fast-changing score qualifies conceptually and belongs in a mini-dimension instead.
The reasoning
The question that decides it is 'would anyone want to see last year's numbers by the old value?'. If yes, the attribute needs Type 2 and an as-of join. If no — a typo fix, a formatting change, an internal note — Type 1 is correct and cheaper, and pretending otherwise adds rows nobody will ever query.
The second question is change rate, and it is what stops Type 2 being the universal answer. An attribute that changes monthly for every customer multiplies the dimension by twelve a year, and one that changes daily makes it unusable. Those belong in a Type 4 mini-dimension keyed from the fact, which captures the value at transaction time without versioning the customer.
Mixing types within one dimension is normal and worth saying explicitly, because candidates often assume a dimension has one type. A single customer dimension can hold a Type 0 signup date, Type 1 corrections to a name, Type 2 on region, and a Type 4 key to a behavioural band — each chosen for that attribute's question.
The last consideration is who is asking. Finance and regulatory reporting usually need as-was and will say so; product analytics often prefers as-is. Where both are needed on the same attribute, Type 6 gives you both views from one row set — a Type 2 history plus a current column updated in place.
Captures the value at transaction time without versioning the whole customer.
Type 2 on everythingavoid
-- every column versioned
Multiplies the dimension by its fastest-changing column and pushes an as-of predicate into every query.
Type 1 on everythingavoid
-- overwrite in place
Cheap, simple, and every published historical report silently restates itself.
The answer most people give
"Type 2 everything — storage is cheap, and you cannot recover history you did not keep." Storage is not the cost. The cost is dimension rows multiplying with the most volatile column and an as-of predicate that every downstream query must now get right.
They’ll ask next
An attribute changes daily and finance does want it historically. Type 2 or Type 4, and what do you lose either way?
You are given a paragraph about a business and asked to model it. What do you ask before drawing anything?
Why they ask this
This is the whole category in one question. An interviewer is watching whether you gather requirements or start drawing boxes, and the ordering of the questions is itself the answer.
Say this
Four, in order: which business process is this, what does one row of it mean, which measures are additive at that grain, and which attributes need history. Then, before drawing: who queries it, how often, and what has to reconcile to what.
The reasoning
**Which business process.** Not which tables the source has — which real-world activity is being measured. Orders placed, shipments despatched, tickets closed. Getting this wrong produces a model shaped like the source system, which is the root cause of the mixed-grain and two-processes-in-one-table defects.
**What does one row mean.** The grain, as a sentence. This is the decision that cannot be changed cheaply later, and it determines whether every subsequent column belongs. Ask for the finest grain the process produces, and require a reason for anything coarser.
**Which measures are additive.** Per measure, per dimension. Anything semi-additive needs a stated time rule; anything non-additive gets split into numerator and denominator. This is a five-minute audit that prevents a whole class of reporting error.
**Which attributes need history.** Per attribute: would anyone ever group last year's numbers by the old value? That decides Type 1 against Type 2, and the change rate decides whether Type 2 or a mini-dimension.
Then the questions that shape the physical model rather than the logical one: who queries this and with what tool, what the largest expected volume is, what latency the business needs, and what this must reconcile against. That last one is the most under-asked — knowing the warehouse must tie to the finance ledger to the penny changes the design of the fact and forces the currency and allocation questions early rather than after launch.
The formulations
Process, grain, additivity, historyship
1. which business process?
2. what does one row mean?
3. which measures are additive, per dimension?
4. which attributes need history?
In that order: each answer constrains the next, and the first is the one that cannot be changed later.
Then the physical questionsship
who queries it, with what tool, at what volume,
and what must this reconcile against?
Shapes the physical model; the reconciliation question is the most under-asked and the most expensive to skip.
Start from the source schemaavoid
-- one warehouse table per source table
Produces a model shaped like the application, with no declared grain and no conformance.
The answer most people give
"Ask for the source schema and start from that." The source tells you what data exists, not what the model should be. Starting there is how you end up with a warehouse shaped like an application database, one table per source table, and no declared grain anywhere.
They’ll ask next
The business says 'we want a customer 360'. Which of your four questions does that fail to answer?
You are modelling the third business process for a warehouse that already has two. What do you do before designing the fact?
Why they ask this
It tests whether the candidate designs a warehouse or a mart. The bus matrix is a cheap artefact that prevents the most expensive integration failure there is.
Say this
Check which dimensions already exist and conform to them rather than building your own. Lay it out as a bus matrix — processes down, dimensions across — so the overlap is visible before code is written, and disagreements are negotiated rather than discovered.
The reasoning
The failure this prevents is two teams shipping two definitions of customer, product or date, after which no report can combine their facts and reconciling them is a project rather than a fix. It costs nothing to check first and a great deal to undo later.
The bus matrix is the artefact: business processes as rows, conformed dimensions as columns, a tick where a process uses a dimension. It is a planning tool rather than a technical one, and its value is that it makes the overlap obvious to people who do not read DDL — including the stakeholders who have to agree that 'active customer' means one thing.
Conforming does not mean identical. A dimension can conform at a coarser grain — a sales fact at product level and a forecast fact at category level can share a conformed product dimension if the category attributes agree. That is a *conformed rollup*, and being able to name it is what lets you integrate processes that genuinely operate at different levels.
What you owe the existing dimensions is a check that they cover your needs, and a negotiation where they do not. Adding an attribute to a shared dimension affects everyone using it, so it is a change with an owner and a review — which is precisely the discipline that keeps it conformed rather than quietly forked.
The formulations
Bus matrix firstship
-- processes down, conformed dimensions across
-- ticks where they intersect
Makes the overlap visible to non-technical stakeholders before any code is written.
Conform to the existing dimensionsship
-- reuse dim_customer, dim_date, dim_product as they are
Negotiate additions with their owner rather than forking; the cheapest possible integration.
Conformed rollup where grains differworks
-- sales at SKU level, forecast at category level,
-- sharing the same category attributes
Lets processes at genuinely different levels still integrate, provided the shared attributes agree.
Build the mart, integrate lateravoid
-- ship it, reconcile in a future quarter
Retro-fitting keys into published facts and reconciling two histories costs an order of magnitude more.
The answer most people give
"Build the mart first and integrate later." Integration later means retro-fitting keys into published facts and reconciling two histories. The check costs an afternoon; the retro-fit costs a quarter.
They’ll ask next
Your process needs product at category level and the existing dimension is at SKU level. Is that a conflict?
One Big Table & wide tablesSemantic layer & metricsMedallion / lakehouse layers
Two teams will use this model: analysts writing ad-hoc SQL, and a dashboard refreshing every minute. Does that change the design?
Why they ask this
It moves the conversation from logical modelling to serving, and the good answer keeps one source of truth while shaping the access layer per consumer.
Say this
It changes the serving layer, not the core. Keep one dimensional model as the source of truth, and derive what each consumer needs from it — a wide aggregate for the dashboard, the star itself for analysts. The mistake is letting the dashboard's needs deform the core model.
The reasoning
Ad-hoc analysis wants a star: conformed dimensions, declared grain, atomic facts, and the freedom to ask questions nobody anticipated. A minute-refresh dashboard wants the opposite — a small number of pre-shaped rows it can read without joining, refreshed incrementally.
Trying to satisfy both with one table produces something that serves neither: too wide and pre-aggregated for analysts to answer new questions, too general for the dashboard to hit its latency. The resolution is layering — one modelled core, and derived serving objects per consumer, each rebuilt from the core rather than loaded separately.
The rule that keeps it honest is that serving objects are *derived and disposable*. If the dashboard aggregate is built by its own pipeline reading the source, you have two definitions of the metric and they will diverge. If it is a materialisation of a query over the core, it cannot disagree, and it can be dropped and rebuilt when a definition changes.
The one thing that genuinely constrains the core design here is latency. If the dashboard needs minute-level freshness, the core fact must be incrementally loadable — which means a reliable event-time watermark and an idempotent merge key — and that is a design decision to take up front rather than retrofit.
The formulations
Modelled core + derived serving tablesship
fct_order_line (core, atomic)
agg_dashboard_hourly -- materialised from the core
One definition, two access shapes, and the aggregate can be dropped and rebuilt at will.
Star onlyworks
analysts and the dashboard both query fct_order_line
One source of truth and the dashboard pays join and scan cost on every refresh.
Separate pipeline for the dashboardavoid
raw -> agg_dashboard_hourly -- built independently
Two definitions of the same metric, loaded from different code, guaranteed to diverge.
The answer most people give
"Build a wide table since that is what the dashboard needs." Optimising the core for one consumer removes the ability to answer anything the other one asks, which is the whole point of having a model.
They’ll ask next
The dashboard aggregate and the star disagree by 0.4%. Where do you look first, and what would have prevented it?
Same model, two businesses: one does 10,000 orders a day, one does 100 million. What actually differs?
Why they ask this
It separates logical from physical design. The logical answer is 'nothing', and being able to say that while listing everything physical that changes is the mark of someone who has run both.
Say this
The logical model does not change — same grain, same dimensions, same keys. What changes is physical: partitioning, clustering, whether history is Type 2 or a mini-dimension, whether aggregates are needed, and whether the load can afford to be anything other than incremental.
The reasoning
Starting with what does not change is the point. Grain, conformed dimensions, additivity and history requirements are properties of the business, not of the volume, and a model that changes shape at scale usually had the wrong grain to begin with.
Partitioning is the first physical decision and it follows the query pattern: partition by the date column that filters are written against — usually event date, not load date — so that a query for one day reads one partition. Clustering or sorting within the partition then targets the next most common filter, typically a high-cardinality key such as customer or product.
At 100 million a day the loading strategy stops being free. Dimensions can no longer be truncate-and-reload, because that reassigns surrogate keys; facts must be incremental with an idempotent merge key so a re-run is safe; and late-arriving data needs a defined window rather than an unbounded backfill. All of these are the same decisions at the smaller volume, where getting them wrong is merely survivable.
The last differences are about what you can afford to ask. A Type 2 dimension whose attribute changes weekly is fine at 10,000 customers and a problem at 100 million, pushing you to a mini-dimension. And pre-aggregates go from an optimisation to a requirement, with the caveat that distinct counts cannot live in them.
The formulations
Same logical model, different physicalship
-- grain, dimensions, keys and history unchanged
-- partitioning, clustering and load strategy differ
The logical model follows the business; only the physical design follows the volume.
Incremental, idempotent loadsship
MERGE ON (order_id, line_no) -- safe to re-run
Mandatory past a certain size, and correct at any size; truncate-and-reload stops being an option.
Type 4 instead of Type 2 for volatile attributesworks
dim_credit_band + credit_band_sk on the fact
Keeps the dimension from multiplying by its fastest-changing column at scale.
Redesign the model for scaleavoid
-- flatten to one wide table, drop the dimensions
A model that changes shape at volume usually had the wrong grain; joins are not the constraint.
The answer most people give
"At that scale you need One Big Table and no joins." Modern engines join large tables efficiently, and a wide table makes attribute changes a full rewrite of the largest object you own. Scale changes the physical design; it does not delete the model.
They’ll ask next
Which of your physical choices would you make the same way at both volumes anyway, and why?
Customers return items. Do returns go in the sales fact as negative rows, or in their own fact?
Why they ask this
Both are used in production and the trade-off is concrete, so it tests reasoning rather than recall — and the answer turns on whether returns have their own attributes.
Say this
Their own fact, in most cases. A return is a separate business process with its own date, reason and approver, and negative rows in the sales fact make every sales metric quietly net-of-returns with no way to separate them.
The reasoning
The deciding question is whether a return has attributes a sale does not. It usually does — a return date distinct from the sale date, a reason code, a condition, an approver — and none of those have anywhere to live on a sales row. That alone points to a separate fact.
Negative rows in the sales fact are appealing because net revenue becomes a plain `SUM` with no join. The cost is that gross revenue becomes hard: every query wanting sales-before-returns needs a filter that people forget, unit counts go negative, and 'orders' counted from the fact now includes return rows. The convenience is bought with ambiguity in every other metric.
Two conformed facts — sales and returns, sharing product, customer and date dimensions — let each carry its own attributes, and combining them is drill-across: aggregate each to a common grain and subtract. That gives gross, returns and net as three explicit metrics rather than one implicit one.
The case for negative rows is genuinely strong in one situation: a ledger-style fact where the business thinks in postings and corrections, and where every row is already signed. There, a return is just another posting, and forcing it into a second table would break the model's own logic.
Each process keeps its own attributes; gross, returns and net are three explicit metrics.
Negative rows in the sales factworks
fct_sales(..., amount) -- returns stored as negative amounts
Right for ledger-style models where every row is a signed posting; elsewhere it hides gross revenue.
A returned flag on the sale rowavoid
fct_sales(..., was_returned BOOLEAN)
A Type 1 overwrite of a fact: the sale row changes after the fact, and partial returns cannot be expressed.
The answer most people give
"Update the original sale row to mark it returned." That mutates a record of something that happened, so a report re-run last month gives a different answer — and a partial return has no way to be represented at all.
They’ll ask next
A return arrives eleven months after the sale, in a different fiscal year. Which date does each metric use?
Warehouses have date functions. Why build a `dim_date` table instead of deriving year and quarter from the timestamp?
Why they ask this
It looks like redundant work and it is one of the highest-value tables in a warehouse, for reasons a date function cannot supply.
Say this
Because a date dimension holds what no date function knows: fiscal calendars, holidays, business-day flags, retail 4-5-4 periods and company-specific labels. It also makes date logic a join rather than a function, so every report agrees on when Q1 starts.
The reasoning
Calendar arithmetic is the easy part and the part functions handle. What they cannot tell you is whether 3 March is a working day in the region concerned, which fiscal quarter your company puts it in, whether it fell in the retail week that anchors last year's comparison, or whether it was a promotional period. Those are business facts and they have to be stored.
Consistency is the second reason and often the bigger one. If fiscal-year logic lives in a `CASE` expression, it lives in a hundred `CASE` expressions, and they will not all agree — particularly around year boundaries. A dimension makes it one column, defined once, and a correction fixes every report at the same time.
It also enables things that are awkward otherwise: role-playing the same calendar as ordered, shipped and delivered dates; joining a fact to a full date range so that days with no activity still appear in a time series; and pre-computed flags like `is_last_day_of_month` that turn a common filter into an equality.
Practical construction notes worth having: generate it well beyond the current date so future-dated facts join; make the key a readable integer such as 20260301 rather than a surrogate sequence, since it is the one dimension where a smart key is conventional and harmless; and add a separate time-of-day dimension rather than putting 86,400 rows per day into this one.
Holds the company facts no date function knows, and makes fiscal logic one column instead of a hundred CASE expressions.
A readable integer keyship
date_key = 20260301 -- YYYYMMDD, not a sequence
The one dimension where a smart key is conventional: it is stable, sorts correctly and needs no lookup to read.
A separate time-of-day dimensionship
dim_time(time_key, hour, minute, day_part)
Keeps the date dimension at one row per day instead of 86,400.
Derive it with date functionsavoid
EXTRACT(QUARTER FROM order_at)
Gives you the calendar and none of the business: no fiscal periods, no holidays, no business-day flags.
The answer most people give
"Use `EXTRACT` and `DATE_TRUNC` — it is the same information." It is the same *calendar* information. Fiscal periods, holidays and business days are company facts that no function can know, and they are usually what the report is grouped by.
They’ll ask next
Why is a smart key like 20260301 acceptable here when it is discouraged everywhere else?
Semantic layer & metricsOne Big Table & wide tables
Three teams compute active customers three ways. Where does the definition belong, and what does that mean for the model?
Why they ask this
Metric drift is the most common trust failure in a warehouse, and the answer sits between modelling and governance — which is where a senior candidate is expected to be comfortable.
Say this
In a semantic layer over a conformed model, defined once and versioned. The model's job is to store the atomic facts and the dimensions the definition needs; the definition itself is not a table, and materialising it as one is how the fourth version appears.
The reasoning
The reason three definitions exist is usually that each team needed a number quickly and the model gave them no shared place to put the answer. The fix is not to pick a winner but to create that place — a semantic layer, metrics store or a governed dbt metric — where `active_customer` is written once, reviewed, and consumed by every tool.
What the dimensional model owes it is the atomic detail. A definition of 'active' as 'ordered in the last 90 days' needs order-level facts with dates; if the warehouse only stores monthly aggregates, the definition cannot be expressed and each team will re-derive it from whatever they have. Modelling at the finest defensible grain is what keeps definitions changeable.
Materialising the metric as a table is where it goes wrong. A `dim_active_customer` freezes one definition into the model, and when marketing needs a 30-day window a second table appears. Keep metrics as definitions over the model and materialise them only as disposable serving objects, rebuilt from the definition.
The governance half matters as much as the technical half: the definition needs an owner, a review process for changes, and a visible history — because a metric that silently changes meaning is worse than three that openly disagree. This is where data contracts and the semantic layer meet.
The formulations
A definition in a semantic layership
metric active_customer:
definition: ordered in the last {window} days
default_window: 90
One definition every consumer inherits, versioned and reviewable, and parameterised where teams genuinely differ.
A definition can only be changed if the detail it needs still exists; monthly aggregates foreclose it.
Materialise it as a disposable serving tableworks
obt_active_customers -- rebuilt from the definition
Fine for performance as long as it is derived from the definition rather than being one.
A dim_active_customer tableavoid
dim_active_customer(customer_sk, is_active)
Freezes one window into the model; the next team needing 30 days creates a second table and a third definition.
The answer most people give
"Build an `active_customers` table and make everyone use it." That is the fourth definition, now with a table behind it. The moment someone needs a different window, a fifth appears — the problem is the absence of a definition layer, not the absence of a table.
They’ll ask next
Marketing needs 30 days and finance needs 90. Is that two metrics, or one metric with a parameter?
Design the data model for a ledger covering deposits, withdrawals, fees and settlements. What is the grain, and what invariant does the model have to make impossible to violate?
Why they ask this
Every fintech loop asks a version of it, and the answer that separates candidates is double-entry — a model where an unbalanced transaction cannot be represented at all.
Say this
One row per ledger entry, not per transaction: every movement is at least two signed rows against different accounts, summing to zero. The balance is derived from the entries, never stored as the truth.
The reasoning
**The grain is the entry, not the transaction.** A withdrawal is not one row — it is a credit against the customer account and a debit against the settlement account, tied together by a transaction id. Choosing the transaction as the grain forces you to invent `from_account` and `to_account` columns, which works until a transaction has three legs (amount, fee, tax) and then the model has to change.
**Double-entry is the invariant, and the point is that it is checkable.** Every `SUM(amount)` grouped by transaction id must be zero. That is a single assertion covering an entire class of bugs — a fee posted without a counterpart, a partial write, a reversal that reversed one leg. A model where entries are signed and grouped by transaction makes the check trivial; a model with a single `amount` and a direction flag makes it a case statement.
**Entries are immutable and corrections are new rows.** You never update a ledger entry. A mistake is fixed by posting a reversal and then the correct entry, so the history shows what happened and what was done about it. This is not fussiness — it is what makes the ledger auditable, and any model that allows an `UPDATE` on an entry has lost the property the ledger exists for.
**Balance is derived, and then cached deliberately.** The true balance is `SUM(amount)` over an account's entries. Computing that over years of history for every query does not scale, so you add a periodic balance snapshot — one row per account per day — and read the snapshot plus the entries since. The snapshot is an optimisation that must be reproducible from the entries, and saying so is what distinguishes it from storing a mutable balance column, which is the design that eventually disagrees with its own history.
**Around that core:** `dim_account` with its type and owner, `dim_asset` or currency (with the amount stored in minor units as an integer, never a float), and `dim_date`. The transaction id is a degenerate dimension on the fact — it groups the legs and there is nothing else to say about it.
Breaks the moment a transaction has a fee leg. Invariant not expressible.
Mutable balance columnavoid
dim_account(account_id, current_balance) -- UPDATEd per txn
Eventually disagrees with the entries, and nothing says which is right.
The answer most people give
"A transactions table with from_account, to_account and amount." It models a two-party transfer and nothing else. A withdrawal with a fee is three legs, a currency conversion touches four accounts, and neither fits — so the schema changes the first time the business adds a fee.
They’ll ask next
A settlement fails and has to be reversed after the daily snapshot ran. What do you write, and what happens to the snapshot?
How do you do column-level masking for PII, and where should the sensitive columns sit in the model in the first place?
Why they ask this
It is asked as a security question and answered best as a modelling one — the placement decision determines whether masking is even possible without rewriting every query.
Say this
Mask at the column with a policy tied to the reader's role, so one table serves every audience. Where the model helps is by isolating PII into few columns in few tables, ideally a separate vault dimension keyed by surrogate.
The reasoning
**The mechanism.** Modern warehouses apply a masking policy to a column: the same `SELECT` returns the real value to one role and a redacted, hashed or tokenised value to another. Snowflake calls it a masking policy, BigQuery does it with policy tags and column-level access, Redshift with dynamic data masking. The important property is that **the policy is attached to the column, not to a view** — so there is one table, one query, and no risk of someone finding the unmasked copy.
**Why views are the weaker answer.** Building `customer_masked` alongside `customer` means two objects to keep in sync, two sets of grants, and a permanent risk that a new pipeline reads the base table. The masked view also drifts: someone adds a column to the base table and it is not in the view, or worse, it is.
**Where modelling comes in.** Masking is cheap when PII is concentrated and expensive when it is smeared across the schema. **Keep identifying attributes in one dimension, or in a separate vault table keyed by the surrogate**, so that most joins and most marts never touch a sensitive column at all. Then the analyst working on order volumes is not querying a table with masked columns; they are querying a table that has none.
**Choose the masking function for the use case, and know what each one costs.** Redaction (`***`) is safest and breaks joins and grouping. Deterministic tokenisation preserves joinability and grouping — the same email always maps to the same token — at the cost of being vulnerable to a dictionary attack on a low-cardinality column. Hashing with a salt is one-way, which is right for a value nobody needs to reverse. Partial masking (last four digits) is what support teams actually need.
**And say the limit out loud:** masking controls the read path. Deletion under GDPR or CCPA is a different problem — a masked row is still a row, and "right to erasure" means the data has to go, which is why lakehouse formats with delete support matter for compliance in a way masking does not address.
The formulations
Column masking policyship
CREATE MASKING POLICY email_mask AS (val string) RETURNS string ->
CASE WHEN CURRENT_ROLE() IN ('SUPPORT') THEN val ELSE '***' END;
ALTER TABLE dim_customer MODIFY COLUMN email SET MASKING POLICY email_mask;
One table, one query, policy travels with the column.
token = hmac(email, key) -- same input, same token
Keeps joins and grouping working. Weak on low-cardinality columns.
A masked view alongside the tableavoid
CREATE VIEW dim_customer_masked AS SELECT ..., '***' AS email
Two objects, two grant sets, and the base table is still there.
The answer most people give
"Create a view with the sensitive columns removed and grant access to that." It works until someone is granted the base table, or a new column is added and forgotten. Masking belongs on the column so there is exactly one path to the data.
They’ll ask next
A customer exercises their right to erasure. Does masking their email satisfy it?