BitBootcampBitBootcamp ← all explainers 🔄 Review ⚙ Settings

DA-01-003-P · Primer — Grain: Knowing What One Row Means

The idea in one sentence

Before you count, sum, or average anything, figure out what a single row is — because every number you compute is silently multiplied by that answer.

Why it exists

The very first sentence a good analyst says about a new table is not "what's the average?" — it's "what does one row represent?" That sentence is the grain (also called the unit of observation): one order, one order-line, one customer, one customer-per-day, one sensor reading. Grain exists as a concept because almost every embarrassing analytics mistake is really a grain mistake in disguise. You say "we have 5,000 sales" when you actually have 5,000 line items across 3,000 orders. You "average the price" but each row is a shipment, so big orders get the same weight as tiny ones. You join two tables and your revenue doubles because one side was finer-grained than you assumed. Naming the grain out loud, before any math, is the cheapest insurance in all of data analysis.

How it works

The move is simple: state a candidate grain in plain English ("I think one row is one order"), then test it by checking whether the column(s) that should be unique actually are unique. If your candidate key repeats, your guess is wrong and the true grain is finer than you thought. Once you know the real grain, every aggregate has an honest name — len(df) counts rows at that grain, and counting orders means nunique() on the order id, not len. The question you are really asking is: "When I say 5 rows, 5 of what?" Here we start with a sales table and interrogate it on tiny inline data.

import pandas as pd

# Raw "sales" table — but what is one row?
sales = pd.DataFrame({
    "order_id": [101, 101, 102, 103, 103],
    "customer": ["Ada", "Ada", "Bo", "Cy", "Cy"],
    "sku":      ["A", "B", "A", "A", "B"],
    "qty":      [1, 2, 1, 3, 1],
})

# Guess 1: is one row one ORDER? Check uniqueness of order_id.
print("rows:", len(sales), "unique order_ids:", sales["order_id"].nunique())
# -> rows: 5 unique order_ids: 3   ... so NOT one row per order

# Real grain: one row = one (order_id, sku) line item. Confirm it's unique:
is_unique = not sales.duplicated(subset=["order_id", "sku"]).any()
print("order+sku unique?", is_unique)        # -> True: that's the true grain

# Now every count has an honest name:
print("orders (correct):", sales["order_id"].nunique())  # -> 3 orders
print("line items:", len(sales))                          # -> 5 line items

The one gotcha

The trap is trusting len(df) as a count of "the thing you care about." len always counts rows at the current grain — never automatically the business entity in your head. If the grain is order-line but you report len as "orders," every downstream number (conversion rate, average order value, revenue per customer) is inflated by the average number of lines per order. Same trap when you sum a per-order total that got copied onto every line — you'll sum it once per line and overcount. Rule of thumb: if you ever need a "distinct" count, that's a signal your rows are finer than your report claims, and you should either aggregate up to the reporting grain first or count with nunique() on purpose.

See it — probe a table's grain before computing

flowchart TD
    A["New table:<br/>what is one row?"] --> B["State a candidate<br/>grain in English"]
    B --> C{"Is candidate key<br/>unique?"}
    C -->|"Yes"| D["Grain confirmed:<br/>len = count of that thing"]
    C -->|"No, it repeats"| E["Grain is finer<br/>than you guessed"]
    E --> F["Add key columns,<br/>re-test uniqueness"]
    F --> C
    D --> G["Count entities with<br/>nunique, not len"]

The one-line takeaway

Name the grain and prove it's unique before your first aggregate — otherwise you're summing and counting the wrong things with confidence.

Where you'll meet it

Every join (fan-out doubles your rows when the other side is finer), every "how many customers/orders/events?" KPI, every dashboard where a total looks 2–3x too big, and every groupby — because groupby is just deliberately changing the grain. You'll reach for this the moment a number feels off, and nine times out of ten the fix is upstream: your rows meant something other than you assumed.

Named concepts: unit of observation, row granularity, uniqueness checks