skip to content

Testing & Documenting Models

A model is only trustworthy if its grain, keys, and relationships are asserted on every run. Interviewers ask what you test on a fact table — grain uniqueness and referential integrity are the expected answers.

on this pageshow

questions

6

How do you test that a fact table actually holds its declared grain of one row per order line?

level: middleimportance: must knowfreq 76%

answer

  1. one row per what, exactly?
  2. the declaration needs an assertion
  3. group by those columns, count them
  4. duplicates usually arrive from a join
  5. a NULL key column hides the duplicate

basics

~20 s

Assert uniqueness on the exact column combination that defines the grain — order id plus line number — and fail the load when duplicates appear. A declared grain is only a claim; the uniqueness test is the proof.

solid answer

~40 s

The grain is a sentence — "one row per order line" — and the test is the mechanical version of that sentence: group by the exact grain columns and assert that no group has more than one row. Equivalently, `count(*)` must equal the count of distinct grain-column combinations. I run it on every model that claims a grain, not just the published fact, because a duplicate that appears in staging is much cheaper to diagnose there. I pair it with not-null tests on each grain column, since a NULL slipping into one of them makes a concatenated-key check vacuous. The test is blocking, never a warning: duplicated fact rows silently inflate every downstream sum, and by the time finance notices, the wrong number has already been in a board deck.

code

sql · 5 lines
sql
-- grain assertion: one row per order line
select order_id, order_line_number, count(*) as row_count
from fct_order_line
group by order_id, order_line_number
having count(*) > 1;

go deeper

for a junior

Be ready to say what grain means in one sentence and write the group-by-having query that checks it. Knowing that duplicates inflate every downstream sum is the point interviewers want to hear.

for a middle

Explain the mechanics: which column combination to group by, why not-null on those columns matters, and how a join to a non-unique table fans rows out. Name the Type 2 dimension case specifically.

for a senior

Show the diagnostic habit — testing each layer so a failure localizes, testing the incremental slice on large facts with periodic full runs, and treating grain violations as blocking rather than warnings.

for a principal

Own the standard: every model declares a grain and every declared grain carries an executable assertion, enforced in review. Be able to argue why that discipline is cheaper than the trust rebuilt after one wrong revenue number.

## What the grain is, and why it needs a test The grain of a fact table is the answer to "what does exactly one row of this table mean?" — one order line, one shipment event, one account balance per account per day. It is declared before you choose dimensions or facts, and everything downstream depends on it being true: additivity, join cardinality, the meaning of `count(*)`, and whether a `SUM` returns money or nonsense. Nothing in a typical warehouse enforces it. Analytical engines generally accept a declared primary key without checking it, and they do not reject duplicate rows. So the declared grain is documentation until you write an assertion that runs on every load. ## The assertion The canonical form groups by the grain columns and looks for any group with more than one row: ```sql select order_id, order_line_number, count(*) as row_count from fct_order_line group by order_id, order_line_number having count(*) > 1 ``` Zero rows returned means the grain holds. An equivalent whole-table form compares `count(*)` against the number of distinct grain combinations; the group-by version is usually preferred because the failing rows are the diagnostic — you can look at the duplicated keys immediately. If the model already carries a single surrogate or hash key built over exactly the grain columns, testing uniqueness of that one column is fine. It is only equivalent when the key is derived from precisely the grain columns and nothing else; a hash that also folds in a load timestamp will be unique on every run while the grain is broken underneath it. ## NULLs quietly defeat naive versions `GROUP BY` treats NULLs as a single group, so the query above does catch two rows that both have a NULL line number. But other formulations do not. A uniqueness constraint permits multiple NULLs. A test built on a concatenated string key is worse: concatenating anything with NULL yields NULL in standard SQL, so several broken rows collapse to one NULL key or vanish from comparison entirely. The robust habit is to assert not-null on every grain column alongside the uniqueness test, so the two together mean "these columns are always present and always unique". ## Why grain tests fail — almost always fan-out The overwhelming cause of a broken grain is join fan-out: the model joined to something that was not unique on the join key, so one input row became several output rows. The classic case is a slowly changing dimension. A Type 2 dimension holds several rows per natural key — one per version — so joining a fact to it on the natural key without a validity predicate multiplies each fact row by the number of versions that customer has ever had. The join looks correct, the row count triples, and every revenue number triples with it. Other recurring causes: joining to a child table one level below the intended grain; at-least-once delivery from a streaming source producing repeated events; and re-running an incremental window without first removing the slice it replaces, which duplicates a day of data on every retry. ```text before: 1 order line joined to 3 dimension versions order 100 / line 1 / amount 50 after: order 100 / line 1 / amount 50 (customer version 1) order 100 / line 1 / amount 50 (customer version 2) order 100 / line 1 / amount 50 (customer version 3) sum(amount) = 150 for a 50 sale ``` ## Where to run it Every model that claims a grain gets the test: the staging model (one row per source record), each intermediate model, and the published fact. Testing only the final mart tells you something is wrong; testing each layer tells you where it went wrong, which is most of the debugging. Dimensions need the same treatment, and this is where people get it backwards. In a Type 2 dimension, the surrogate key is unique and the natural key is deliberately not — several rows share it. Asserting uniqueness on the natural key of a Type 2 dimension will fail on correct data. The right assertions there are: surrogate key unique, and the combination of natural key plus effective-from unique, with at most one row flagged current per natural key. ## Severity and cost Grain uniqueness is a blocking check. Downstream, a duplicate is not a visible error — it is a plausible, wrong number, which is far more expensive than a failed pipeline. On very large facts the group-by is a full scan, so a common compromise is to test only the slice an incremental run touched on every build, with a full-table run on a schedule. That keeps the cost bounded without letting historical duplicates accumulate unnoticed.

  • Your grain test passes in staging but fails in the published fact. What does that narrow it to?
    The duplication was introduced by the transformation between them, which almost always means a join fanned out. I look at each join in that model and check whether the right-hand side is unique on the join key — most often a Type 2 dimension joined without a validity predicate, or a join to a table that sits one level below the intended grain.
  • A fact table is 20 billion rows. Do you still run a full grain test on every build?
    No. I assert the grain on the slice each incremental run writes, which is cheap, and schedule a full-table run less often — nightly or weekly. That catches new breakage immediately and still bounds historical drift. The tradeoff is a window where an old duplicate can hide, which I accept explicitly rather than by omission.
  • Why not just declare a primary key and let the warehouse enforce it?
    Most analytical engines accept a primary-key declaration as metadata and never validate it — some even use it for query rewriting, which makes an unenforced, untrue key actively dangerous. The declaration is useful documentation and an optimizer hint, but the run-time assertion has to be a query you execute yourself.

A declared grain is like the label on a box. The uniqueness test is opening the box and counting what is inside; without the count, the label is only someone's intention.

saying these in an interview costs you the question

  • Declaring the grain in a comment and never asserting it
  • Testing uniqueness on the natural key of a Type 2 dimension
  • Assuming the warehouse enforces a declared primary key
  • Only testing the final mart, so fanout is untraceable
  • Downgrading a duplicate-row failure to a warning

context

open as a page

How do you test referential integrity between a fact table and its dimensions when the warehouse doesn't enforce it?

level: middleimportance: must knowfreq 64%

basics

~20 s

Run an anti-join on every load: each fact row's dimension key must find a match in the dimension, and any that does not is returned as a failure. Orphan keys never error at query time — they just disappear from inner-join reports.

open as a page

In a warehouse dimension table, what do not-null and accepted-values tests each catch?

level: juniorimportance: should knowfreq 60%

basics

~20 s

A not-null test asserts a column is always populated — keys, effective dates, the default unknown member. An accepted-values test asserts the column only ever contains a listed set of codes, so a new source value nobody mapped fails loudly instead of appearing in a report.

open as a page

How do you define a freshness check on a source table feeding a warehouse model?

level: middleimportance: should knowfreq 46%

basics

~20 s

A freshness check compares the newest timestamp in a source table against the current time and fails when the gap exceeds the expected load cadence plus slack. It catches the pipeline that succeeded on schedule over data that stopped arriving.

open as a page

How do you reconcile a mart's row counts and measure sums against the source system?

level: seniorimportance: should knowfreq 50%

basics

~20 s

Compare aggregates, not rows: for each business day, count rows and sum each measure on both sides, store both results with the difference, and alert when the gap exceeds a documented tolerance. Reconcile by business date, never by load date.

open as a page

How do you keep column definitions and lineage trustworthy for a published analytics model?

level: principalimportance: nice to knowfreq 35%

basics

~20 s

Definitions live with the model as reviewed code, every model has a named owner, and lineage is derived from parsed SQL rather than drawn by hand. Tests are the part of the documentation that cannot silently drift.

open as a page