Grain: What Does One Row Represent?
The single most important question in analytical modelling. Answer it in one sentence per table, or every aggregate downstream is a guess.
Who needs this, what one row is, and why the obvious build breaks
Every lesson starts from the consumer, because designing from the source outward is this domain's characteristic mistake.
What does exactly one row of this table represent, and what happens to every metric if two people answer that differently?
Anyone who writes COUNT(*), SUM() or a join. A grain that is undeclared is not a documentation gap — it is a defect that every consumer reproduces independently, because each one infers a grain from the column names and each inference is a guess.
This lesson is about grain itself, so its unit is the declaration: one sentence, per table, of the form "one row is one X". The sentence must contain no "and", no "usually", and no "or" — every one of those words hides a second grain that will eventually be summed against the first.
Skip the declaration. The table is called fct_orders, the columns are obviously about orders, and everyone knows what an order is. Write the transformation, ship it, and let the grain be whatever the GROUP BY produced.
A LEFT JOIN to a dimension that unexpectedly has two rows per business key silently doubles every row of the fact, and every measure with it. The join succeeded, the row count grew, and no test noticed because nobody asserted the row count (Surrogate Keys).
- A
LEFT JOINto a dimension that unexpectedly has two rows per business key silently doubles every row of the fact, and every measure with it. The join succeeded, the row count grew, and no test noticed because nobody asserted the row count (Surrogate Keys). - An order-grain fact is joined to a line-grain fact to get product mix.
shipping_feeandorder_discountare now repeated once per line, and total shipping is reported at roughly the average lines-per-order multiple of the truth — a stable, plausible, permanently wrong number. COUNT(*)onfct_paymentsis used as "orders". A retried card payment produced three payment rows for one order, so the order count follows payment-provider reliability instead of the business (Fact Tables).- A "daily active users" table is at user-day grain, and someone sums the daily counts across a month to get "monthly active users". The result counts each user once per day they appeared, and is several times the real MAU (Snapshot Tables).
- Two teams both build a
revenue_dailymart from the same fact. One aggregates at order grain, the other at line grain after a join. Both reconcile against their own upstream, they disagree with each other by a few percent, and there is no artefact anywhere that explains why (Two Dashboards, Two Numbers).
What is actually happening
- Grain is the unit of observation the table commits to. Every measure in the table must be a quantity of that unit, and every aggregate is only meaningful if the rows being aggregated are distinct observations of it.
- Aggregation is safe when the grain is known and violated when it is not, because
SUMhas no way to distinguish "two orders of 50" from "one order of 50 that appears twice". Both are two rows of 50 and both sum to 100. The database is correct in every step; the model lied about what a row was. - A join changes grain whenever the join key is not unique on at least one side. One-to-one preserves grain. Many-to-one — a fact to a properly keyed dimension — preserves the fact's grain, which is exactly why star schemas are safe. Many-to-many multiplies, and that multiplication is arithmetic, not an error condition (Joins: INNER, LEFT, RIGHT, FULL, CROSS, SELF).
- A
GROUP BYalso changes grain, deliberately: it produces a new table whose grain is the grouping key. The bug appears when the result of aGROUP BYis joined back to something at the original grain and re-aggregated, which double counts what was already counted (Aggregation: COUNT, SUM, AVG, GROUP BY, HAVING). - Grain violations are conservative in structure and catastrophic in value. Types stay correct, keys stay valid, no null appears, no constraint fires. Only the magnitude is wrong, and magnitude is exactly the thing nobody can independently verify.
- The reason grain errors survive so long is that they usually inflate rather than deflate. A number that comes in low gets investigated within a day; a number that comes in high gets celebrated, and then gets built into a forecast (Trusting Data).
One sentence per table, and what happens without it
The exercise is short. Take every table in your serving layer and write "one row is one ___". If any sentence needs an "and", the table has two grains and one of them will be summed against the other. If any sentence needs a "usually", the grain is a hope.
The value of the exercise is not the documentation. It is that writing the sentence forces you to name the business key that makes it unique, and naming that key is the same act as writing the test that enforces it. A grain you can state is a grain you can check.
The table below walks a single customer's activity through the layers of a normal platform. Every row of it is a legitimate grain and every adjacent pair is a place where a metric can silently change by a multiple. Read the breaksIf column as a list of incidents that have happened to real teams.
| Stage | One row is | Breaks if |
|---|---|---|
| Source `orders` table | One order, in its current state, with status mutated in place. | You treat it as history. The three status changes this order went through are not in this table and never were (Operational vs Analytical Models). |
| CDC change stream | One *change* to one order — insert, update or delete. | You count rows and call the result orders. Four updates to one order are four records and one order (What a CDC Event Contains). |
| Raw landed files | One delivered change record, possibly delivered more than once. | You assume each change was delivered a single time. At-least-once delivery means duplicates are the normal case, not the exception (The Raw Landing Zone). |
| `stg_orders` | One order, reconstructed as the latest change per order_id. | Latest is picked by arrival order rather than by source commit order, so an out-of-order update wins and the order's status goes backwards (CDC Ordering and Transaction Boundaries). |
| `fct_orders` | One order. Measures are order totals: revenue, shipping, discount. | It is joined to fct_order_lines without aggregating first. Every order-level measure is then repeated once per line. |
| `fct_order_lines` | One product line within one order. | Someone counts rows and calls it orders, or sums an order-level measure that was copied down onto the lines. |
| `revenue_daily` mart | One country-day with revenue pre-summed. | It is joined back to an order-level table and re-aggregated, double counting what was already counted (Aggregation: COUNT, SUM, AVG, GROUP BY, HAVING). |
| Dashboard tile | One number. The grain is now completely invisible. | The BI tool applies its own join or filter, changing the grain after every upstream test has passed (Dashboards Built Around Questions). |
Seven grain changes between a customer clicking Buy and an executive reading a number. Every one is legitimate and every one is a place where "one row" quietly starts meaning something else.
The join that multiplies money
This is the concrete case worth memorising, because it is the one that recurs. An order-grain fact holds shipping_fee, which is genuinely an order-level amount — one shipment, one fee. A line-grain fact holds one row per product in the order. Someone needs revenue by product category, so they join the two.
The join is correct SQL. It is a one-to-many join, which is the normal way to get from orders to their lines, and it produces exactly the rows you would expect. What it also does is repeat every order-level column once per line, and SUM(shipping_fee) across the joined result is now the shipping fee counted once per line rather than once per order.
Work the numbers below by hand once. Order O-1001 has three lines and a shipping fee of 5.00. After the join, SUM(o.shipping_fee) returns 15.00 for that one order. Across a whole dataset the inflation factor is exactly the average number of lines per order — a quantity that is stable within a business and different between businesses, which means the reported shipping cost comes out consistently and plausibly wrong by a multiple that looks like nothing in particular.
The fix is not to be careful. Being careful does not survive the fourth analyst. The fix is structural: aggregate one side to the other's grain in a CTE before joining, so that the join is one-to-one and cannot fan out, and put the order-level measures only on the order-level table so the mistake is not expressible.
1-- WRONG: order-level measure summed across a line-level join2SELECT p.category,3 SUM(l.revenue) AS revenue, -- correct: line-grain measure4 SUM(o.shipping) AS shipping -- WRONG: repeated once per line5FROM fct_orders o6JOIN fct_order_lines l ON l.order_id = o.order_id7JOIN dim_product p ON p.product_key = l.product_key8GROUP BY 1;9 10-- RIGHT: aggregate to a common grain first, then join one-to-one11WITH lines_by_order AS (12 SELECT order_id,13 SUM(revenue) AS line_revenue,14 COUNT(*) AS line_count15 FROM fct_order_lines16 GROUP BY 1 -- grain is now: one order17)18SELECT o.order_id,19 o.shipping, -- counted once, because one row20 l.line_revenue21FROM fct_orders o22JOIN lines_by_order l USING (order_id); -- one-to-one: grain preserved23 24-- RIGHT, the other direction: product mix stays at line grain and25-- simply does not carry order-level measures at all.26SELECT p.category, SUM(l.revenue) AS revenue27FROM fct_order_lines l28JOIN dim_product p ON p.product_key = l.product_key29GROUP BY 1;The second and third queries are both correct and answer different questions. What makes them safe is not care — it is that neither one has an order-level measure and a line-level row in the same scope, so the mistake has nowhere to occur.
orders (order grain) order_lines (line grain)
+----------+---------+----------+ +----------+------+----------+---------+
| order_id | revenue | shipping | | order_id | line | product | revenue |
+----------+---------+----------+ +----------+------+----------+---------+
| O-1001 | 120.00 | 5.00 | | O-1001 | 1 | Keyboard | 60.00 |
| O-1002 | 40.00 | 5.00 | | O-1001 | 2 | Mouse | 25.00 |
+----------+---------+----------+ | O-1001 | 3 | Cable | 35.00 |
| O-1002 | 1 | Monitor | 40.00 |
+----------+------+----------+---------+
SELECT SUM(o.shipping) FROM orders o JOIN order_lines l USING (order_id)
after the join, one row per LINE, order columns repeated:
+----------+------+----------+----------+
| order_id | line | product | shipping |
+----------+------+----------+----------+
| O-1001 | 1 | Keyboard | 5.00 | <- the same 5.00
| O-1001 | 2 | Mouse | 5.00 | <- fee, three
| O-1001 | 3 | Cable | 5.00 | <- times
| O-1002 | 1 | Monitor | 5.00 |
+----------+------+----------+----------+
true total shipping = 10.00 (2 orders x 5.00)
SUM(o.shipping) = 20.00 (4 lines x 5.00)
inflation factor = average lines per order = 2.0
No error. No null. No type change. No constraint violated.
Row count went up, which looks like more data arriving.Making grain a testable property
A grain that exists only as a sentence degrades. Sources change, joins get added, a dimension gains a duplicate, and the sentence stays true in the documentation long after it stopped being true in the table.
The three checks below are cheap, run on every load, and between them catch nearly every mechanical grain violation. What they cannot catch is a grain that has been consistently misunderstood since the table was created — which is why the fourth row exists and why the only defence against it is an external comparison.
Note the misses column on the uniqueness row in particular. Testing uniqueness on a surrogate row identifier rather than on the business key is the most common way a grain test provides false confidence: it passes on every duplicate, because the pipeline generated a fresh identifier for each one.
| Check | Expresses | Catches | Still misses |
|---|---|---|---|
SELECT key, COUNT(*) ... GROUP BY key HAVING COUNT(*) > 1 on the declared business key | Exactly one row per unit of observation — the grain declaration, written as a query. | Fan-out joins, duplicate source events, non-idempotent re-runs, a dimension that gained a second row for one business key. | Everything if the key is a generated surrogate row id, which is unique by construction. Also duplicates that differ in the key — the same order re-emitted with a new event id looks like two orders (Deduplication). |
| Output row count equals input fact row count across a many-to-one join | This join is supposed to add context, not rows. | The fan-out at the exact join that caused it, before it reaches a table, which turns a three-week investigation into a failed test with a stage name on it. | Joins that both drop and duplicate rows in equal measure, where the count is preserved and the contents are not. Also any grain change made by a GROUP BY rather than a join. |
| Ratio of row count to distinct business keys, tracked over time | The grain has not drifted since yesterday. | Slow, partial fan-outs affecting a subset of keys — the kind a threshold on total volume would read as a good day. | A uniform fan-out present since the table was created, because the ratio has been 3.0 forever and looks stable. It measures change, not correctness. |
| External count comparison — orders per day versus the payment provider or the product admin panel | Our unit of observation matches the business's unit of observation. | A consistently wrong grain, which is the one thing no internal test can see, because every internal artefact shares the misunderstanding. | Anything the external system also counts differently, and everything about measures rather than counts. It is slow, manual, and worth doing quarterly (Reconciliation). |
The first three tests protect the grain from drifting. Only the fourth can tell you the grain was wrong to begin with, and it is the only one that requires leaving the platform.
Where a grain error travels
Grain errors do not stay where they are made. The multiplication happens at one join and then propagates through every model, mart, dashboard and export downstream of it, arriving in places that have no obvious connection to the join that caused it.
Walking the chain below during an incident is the practical skill. At each node the question is the same — is the row count here what it should be, and does the distinct business-key count match it? The first node where those two numbers separate is where the fault was introduced; everything above it is a victim.
The last node is the one people forget. A finance export that was already sent, a forecast already built on it, a bonus calculation already run. Data repair fixes the table; it does not un-send the email, and that is why grain incidents are announced rather than quietly corrected (Data Incidents).
- `stg_customers`
holds One row per customer, from the latest change per id.
could corrupt A source that emitted two records for one customer, or a merge that failed to deduplicate — this is where the extra row usually enters.
↑ reads from - `dim_customer`
holds One row per customer version, unique on
(customer_id, valid_from).could corrupt Two rows marked
is_current = truefor one customer, which converts every fact join into a two-way fan-out (SCD Type 2 in Practice).↑ reads from - `fct_orders` build
holds One row per order, after joining the customer dimension for its key.
could corrupt The join to the duplicated dimension row doubles the fact. Row count doubles; distinct
order_iddoes not.↑ reads from - `revenue_daily` mart
holds One row per country-day, revenue pre-summed.
could corrupt Nothing new — it faithfully aggregates a table that already has each order twice, and now the error is compressed into a number nobody can decompose.
↑ reads from - Executive dashboard
holds One revenue figure per month, with filters applied in the BI layer.
could corrupt Nothing new, and it is where the error is finally seen — as a very good month.
↑ reads from - Finance export and forecast
holds A file that left the platform and a model built on it.
could corrupt Nothing technically. This node cannot be repaired by a backfill, which is why the incident process matters more than the fix (Debugging a Data Incident).
The fault is at node two and the symptom is at node five. Walking upstream and comparing row count against distinct-key count at each node finds it in minutes; guessing from the symptom does not find it at all.
How to build it
Most important first.
- Write the grain sentence before writing the SQL, and put it in the model's description where the catalog and the BI tool will show it. "One row per order" and "one row per order line" are different tables and should never be the same table (Dataset Documentation).
- Name the business key that makes the grain unique, and test uniqueness on it every load. The grain declaration and the uniqueness test are the same statement written twice, once for humans and once for the machine (Data Tests).
- Model at the finest grain the source supports and derive coarser tables from it by aggregation. Coarse-to-fine is impossible; fine-to-coarse is a
GROUP BY(Fact Tables). - Never join two fact tables directly. Aggregate one to the other's grain first, in a CTE, and the fan-out cannot happen (Subqueries, CTEs, EXISTS, UNION, CASE).
- Keep order-level measures out of line-level tables. If a consumer insists on having them together, allocate the order-level amount across the lines so that the sum still equals the order total, and document the allocation rule where the column is defined.
- Assert the row count of a transformation against its input where the grain is supposed to be preserved. A fact-to-dimension join that changes the row count has a bug, and this is a one-line test (The Dimensions of Data Quality).
What this actually promises
Naming the guarantee you do not have is worth more than naming the one you do — everything downstream inherits the weakest promise in the chain.
- A declared grain guarantees nothing on its own. It becomes a guarantee only when a uniqueness test on the business key runs on every load and blocks publication on failure (Contract Enforcement).
- A many-to-one join to a dimension with an enforced unique key guarantees the fact's row count is preserved. Analytical engines usually do not enforce that key, so the guarantee is inherited from your test, not from the schema (Database Constraints).
- No engine guarantees that a measure belongs at the table's grain.
shipping_feeon a line-grain fact is valid SQL, valid data, and a permanent trap. - Reconciliation against a source guarantees consistency of a total, not of a grain. A line-grain fact and an order-grain source can reconcile perfectly on summed revenue while disagreeing completely on row counts, which is the point of both checks existing.
Can I trust it?
A green pipeline is evidence that code ran. These four fields are the evidence that the data is right.
- The check is uniqueness on the declared business key, run on every load, blocking publication.
SELECT key, COUNT(*) FROM tbl GROUP BY key HAVING COUNT(*) > 1— one query, and it is the highest-value data test that exists, because grain violations are silent, inflating, and reconcile against nothing. - Pair it with a row-count preservation assertion across every join that is supposed to be many-to-one: the output row count must equal the fact's input row count exactly. This catches the fan-out at the moment it happens rather than in a dashboard three weeks later, and it localises the bug to a single join instead of to a model.
- Pair both with a distinct-key versus row-count ratio monitored over time. A ratio that is 1.00 every day and 1.03 today is a fan-out that a threshold-based volume check would not have flagged, because a 3% row increase looks like a good sales day (Distribution Tests).
- What all three still miss: a grain that is uniformly wrong. If the table has been at line grain since the day it was built and everyone believes it is at order grain, uniqueness passes on
(order_id, line_number), row counts are stable, ratios are flat, and every order-level metric derived from it has been wrong from the beginning. Nothing internal can detect a consistent misunderstanding — only a comparison against a system that counts orders for a different reason, such as the payment provider's statement or the product's own admin panel (Reconciliation). - And none of them detects the inverse: a correct grain that is documented wrong. The data is fine, the sentence in the catalog is not, and every new analyst inherits the error. Grain is checked in two places — the test and the description — and only one of them runs.
- Grain has no freshness of its own, but it decides how expensive freshness is. A fine grain means more rows per period to load, so the same latency target costs more compute at line grain than at order grain.
- Late-arriving rows interact with grain: a late order line changes an order-grain aggregate that was already published, whereas at line grain it is simply a new row. The coarser the grain, the more restatement late data causes (Late-Arriving Data).
- Derived aggregates lag their base table by one more scheduling interval, and consumers reading the aggregate are further behind than they realise (Data Marts).
- A grain change is a breaking change with no schema signature. Splitting one order row into one row per line adds no column and removes none, and every aggregate written against the table is now wrong in a way no compatibility check can see (Breaking Schema Changes).
- The correct mechanism is a new table with a new name, built in parallel, with the old table deprecated on an announced schedule while consumers migrate (Impact Analysis).
- Grain can drift without anyone deciding: a dimension gains duplicate rows, a source starts emitting a second record per event, an upstream model adds a
LEFT JOIN. Drift is why the uniqueness test runs every load rather than once at design time (Data Observability). - When history was produced at two different grains — before and after a change — the two periods are not comparable, and any chart spanning the boundary is a lie with a trend line on it.
- A fan-out that was published is repaired by fixing the join and rebuilding the affected partitions from retained inputs. The data recovery is easy; the trust recovery is not, and the second one is why the announcement matters (Data Incidents).
- A table built at the wrong grain from the start is rebuilt only if the raw inputs at the finer grain were retained. If ingestion aggregated on the way in, the detail is gone and the fix requires re-ingesting from the source, bounded by whatever history that source still holds (Keeping Raw History: The Recovery Position and the Liability).
- Rebuild into a new location and swap. Repairing a grain bug by mutating the table consumers are reading turns a wrong number into a wrong number that also changes while people look at it (Atomic Publish).
- Restate publicly. Every downstream report produced during the affected window is also wrong, and lineage is how you find them rather than guessing (Lineage Debugging).
What can go wrong
- A fan-out join multiplying every measure by a factor that varies by row, so the error is not even a constant you could divide out.
- Summing a per-period count across periods — daily actives summed to monthly actives — which double counts every returning entity (Snapshot Tables).
- Averaging an average: taking the mean of per-day averages rather than recomputing from the base, which weights a quiet Sunday the same as a busy Monday (The Average Was Fine and Users Were Not).
- A dimension that gained duplicates during a re-run, converting a safe many-to-one join into a many-to-many one overnight (Duplicate Rows).
- The mitigation failing: a uniqueness test written against a generated surrogate row id rather than the business key. It passes on every duplicate, because each duplicate received its own row id.
- "The row count went up, so we got more data." A fan-out increases row count. So does a duplicate load. So does a genuinely good day. The three are distinguishable only by comparing distinct business keys, not rows (Volume Anomalies).
- "The join is a
LEFT JOIN, so it cannot add rows." ALEFT JOINguarantees you keep every left row at least once. It says nothing about keeping each one only once (Joins: INNER, LEFT, RIGHT, FULL, CROSS, SELF). - "Our reconciliation passes, so the grain is fine." Reconciliation on a summed measure is insensitive to grain: a line-grain table and an order-grain source can agree exactly on revenue and disagree by a factor of four on counts.
- "We can figure out the grain from the primary key." Analytical tables usually have no enforced primary key, and a declared one is often a surrogate that is unique by construction and says nothing about the business unit (Surrogate Keys).
- "It is just documentation." It is the statement that makes every aggregate in the platform either meaningful or arbitrary, and it is the cheapest artefact in the entire discipline.
Operating it
- Row count and distinct business-key count, per table per load, plotted on the same axis. They should be identical lines; the day they separate is the day of the incident (Pipeline Metrics).
- Row count in versus row count out for each join stage in the transformation. Fan-out localises immediately instead of being searched for across a DAG (Data Lineage).
- The grain sentence rendered in the catalog and in the BI tool's field description, so a consumer sees it where they are about to make the mistake (The Data Catalog).
- At 10x, a grain that was merely finer than necessary becomes the reason queries need partition pruning to be feasible at all (Partition Pruning).
- At 100x, the cost of a grain mistake is dominated by reprocessing: fixing it means rebuilding all history at the new grain, and that job may not fit in any maintenance window (Planning a Backfill).
- Consumer count changes the blast radius rather than the mechanics. One grain bug in a table read by eighty dashboards is eighty conversations, and lineage is the only way to have them in an afternoon (Impact Analysis).
- Grain is the largest single cost lever in a warehouse: row count times row width is the scan cost of every question ever asked at that grain (Scan Cost).
- A fan-out is also a cost incident, not only a correctness one. A join that multiplies rows multiplies the shuffle, the storage and every downstream scan until it is caught (The Shuffle).
- Deriving coarse aggregates from a fine base costs one extra model and pays for itself across every consumer query that no longer scans the fine table (Data Marts).
- Declaring and testing grain costs a sentence, a test and a small amount of runtime per load. It buys the only reliable defence against the failure class that produces confident wrong numbers. There is no serious argument on the other side.
- Modelling at the finest grain buys answerability and costs storage and scan on every query. The mitigation — derived aggregates — costs a second model and one more thing that can be stale.
- Refusing to join fact tables directly costs an extra CTE and some analyst patience, and buys immunity from the most common fan-out in analytics.
Modeling lab — one grain, ten questions
Change an input and watch which number moves — and which one does not. Everything here comes from a model in this repository, not from a measurement.
| Business question | At this grain | Why |
|---|---|---|
What was revenue by country last month? Wants: One order, or one order line — either works, provided the measure is additive at that grain and is not summed twice. | answered | The measure is additive at this grain and each order is counted once. |
What is the average order value? Wants: One order. An average over lines answers a different question entirely. | answered | The denominator is orders, which is exactly what one row is. |
What was revenue by customer country at the time of each order? Wants: One order, joined to the version of the customer that was current when the order was placed. | WRONG no history | With a Type 1 dimension every historical order is attributed to the customer's current country. A customer moving from Poland to Germany silently rewrites last year's regional reports, and last month's report no longer reproduces. |
What is net revenue after refunds? Wants: One order, with refunds either netted into the measure or held as a separate signed fact at the same grain. | answered | Refunds net into the measure, or sit beside it as a signed fact at the same grain. |
What was the total account balance on each day last year? Wants: One account-day. A balance is a state, not an event, and cannot be reconstructed by summing transactions unless every transaction since account opening is retained. | WRONG | Summing transactions per day gives the daily change in balance, not the balance. The chart has the right shape and the wrong y-axis. |
What is month-three retention by signup cohort? Wants: One user-month of activity, joined to the user's signup month. | WRONG | Users who were active but did not buy are invisible, so retention is understated by exactly the non-buyers. |
What share of sessions ended in a purchase? Wants: One session — which requires a session window over events, because no source system emits a session. | unanswerable | No source system emits a session. Without a session window over events there is no denominator to divide by. |
Which products are most often bought together? Wants: One order line, with the order key retained so lines can be grouped back into baskets. | unanswerable | The most instructive failure in this lab: the model is not wrong, it is at the wrong resolution, and no query can recover what was aggregated away. |
What was yesterday's revenue, asked at 06:00 this morning? Wants: One order, in a period that is not yet closed. | with care | The grain is right and the period is not closed. Orders that happened yesterday and arrive later today are still missing at 06:00. |
What was global revenue, across markets that bill in different currencies? Wants: One order, with both the transaction amount and the converted amount stored, plus the rate and the date the rate applied. | WRONG no history | Converting at query time with today's rate makes every historical report change daily. Converting once with no record of the rate makes the number unreproducible. Both pass every type check there is. |
| Stage | One row is | Rows in | Rows out | Δ |
|---|---|---|---|---|
Source database | One order, in its current state. | 4,000 | 4,000 | — |
Change capture | One committed change to one order. | 4,000 | 4,000 | — |
Event log | One delivered change record — possibly delivered more than once. | 4,000 | 4,000 | — |
Raw landing | One line in an immutable file, exactly as received. | 4,000 | 4,000 | — |
Transformation | One order, deduplicated and windowed. | 4,000 | 4,000 | — |
Serving table | One order, with measures and dimension keys. | 4,000 | 4,000 | — |
Dashboard | One number, with the grain now invisible. | 4,000 | 1 | aggregated |
Row counts are simulatedsim. The last row is where the grain disappears: one number, with nothing on the screen recording what one row of the source meant.
| Check | Result | What the model found | Still misses |
|---|---|---|---|
Completeness Every order the source recorded for the period reached the serving table. | pass | Every order in the source for this period is present. | Duplicates that coincidentally offset losses, and any period that is not yet closed. |
Uniqueness Each order id appears exactly once in the serving table. | pass | Every order id appears exactly once. | A genuine duplicate that arrived under a new key — a producer retry with a fresh event id looks like a second order. |
Freshness The newest complete record is recent enough for the decisions this table drives. | pass | Newest complete record is 12 simulated minutes old. | Data that is perfectly fresh and completely wrong. It also fires falsely on a period where the source genuinely produced nothing. |
Validity Every amount is non-null and parses as a number. | pass | Every amount is non-null and numeric. | A value that is well-typed and wrong — a price in the wrong currency passes every type check there is. |
Distribution The shape of the day resembles the days before it, per country and in total. | pass | Largest per-country share drift 0.8pp; total volume drift 0.0%. | Slow drift, and any error that preserves the shape while changing every value inside it. |
Reconciliation Revenue summed in the serving table equals revenue summed in the source for the same closed period. | FAIL | Serving table reports 1,010,654.50 against a source total of 934,498.90. | Anything wrong identically at both ends — a bug in logic shared by the extract and the model reconciles perfectly. |
- 1Refunded orders were counted at their full value. Every row is present, unique, fresh and well-typed — and revenue is overstated.
The revenue model stops subtracting refunds.
The code does exactly what it was told, and what it was told is wrong. Every row is present, unique, fresh, well-typed and normally distributed — and the number is too high.
Where this applies
Almost nothing here is universal. These labels say what each claim is specific to, and where a different engine, format, warehouse or scale would differ.
- GENERALGrain is a property of the data and of the questions asked of it, not of any engine. It applies identically to a warehouse table, a Parquet dataset on object storage, a stream window output and a pandas dataframe, and the failure looks the same in all four.
- SIMPLIFIEDPresenting grain as one sentence per table is the teaching form. Real models carry mixed-grain facts on purpose — an accumulating snapshot whose columns advance at different times, or a bridge table resolving many-to-many relationships — and those need an explicit statement of which measures are valid at which grouping rather than a single sentence.
- TOOL-SPECIFICTransformation frameworks differ in whether the grain declaration is executable: some let you attach a uniqueness test to a model definition so the declaration and the check are one artefact, while a hand-written SQL pipeline keeps them in separate places where they can drift apart silently.
Where the depth lives
This domain teaches how data moves and how you know it arrived intact. It hands the rest off by name.
- — Distributed Systems owns why at-least-once delivery is the default and what it takes to get an effectively-once result, which is the upstream cause of most duplicate rows that turn into grain violations.