Grain
What does exactly one row represent?
One customer? One order? One status change?Before you join tables or ask AI to query them, understand what one row means. Most expensive analytics mistakes begin with misunderstood grain.
Before asking “how do I join these tables?” ask “what does one row represent?”
You found that cancellation rate increased sharply. Now the operations team wants a city-level breakdown with payment and cancellation reasons.
This sounds straightforward: join orders, customers, cities, status events, payments and promotions.
But three of those tables can contain multiple rows for a single order. A technically valid join can therefore produce a mathematically wrong answer.
What does exactly one row represent?
One customer? One order? One status change?What should uniquely identify that row?
customer_id, order_id, event_id.Which field links this row to another entity?
orders.customer_id → customers.customer_id.How many rows can exist on each side?
One-to-one, many-to-one, or one-to-many.If you cannot state the grain in one sentence, you are not ready to aggregate or join the table.
1 row per customer
customer_idsignup_datecity_idacquisition_channel 1 row per order
order_idcustomer_idcreated_atcompleted_atstatusgross_value 1 row per order status change
event_idorder_idstatusevent_timereason_code 1 row per payment attempt
payment_idorder_idattempt_nopayment_statusamount 1 row per city
city_idcity_namecountrylaunch_date 1 row per promotion applied to an order
order_promo_idorder_idpromo_codediscount_amount orders behaves like a business fact table: it records measurable transactions. customers and cities are dimensions that describe those transactions. order_status_events is an event history: many events can belong to one order.
Aggregate item-level facts separately or keep the order-level measure at order grain.
The SQL can be syntactically perfect while GMV is now overstated by 38%.
| Join | Relationship | What can go wrong? |
|---|---|---|
| orders → customers | many-to-one | Safe if customer_id is unique in customers. |
| orders → cities | many-to-one | Safe if each city_id appears once in cities. |
| orders → status events | one-to-many | Duplicates order rows unless you aggregate or choose one event first. |
| orders → payments | one-to-many | Multiple payment attempts can multiply order value. |
| orders → promotions | one-to-many | Multiple promos per order can duplicate revenue if joined naively. |
orders
order_id gross_value
1001 25.00 order_status_events
1001 created
1001 assigned
1001 cancelled The order did not suddenly become worth three times more. The join changed the grain.
Start from the required grain: one row per order.
Reduce events to one row per order: final status, cancelled_at, reason.
Reduce attempts to one row per order: attempts, paid flag, paid amount.
Aggregate discounts to one row per order.
Join customer and city attributes after uniqueness checks.
I need an analysis table with exactly one row per order.
Tables:
- orders: one row per order
- customers: one row per customer
- cities: one row per city
- order_status_events: one row per order status change
- payments: one row per payment attempt
- promotions: one row per promotion applied to an order
Before writing SQL:
1. State the grain of every table.
2. Describe the expected relationship for every join.
3. Identify which joins can duplicate order rows.
4. Propose any pre-aggregation needed to preserve one row per order.
5. List validation checks I should run after joining.
Do not write the final SQL until the grain plan is explicit. If an AI assistant starts writing joins without discussing grain, keys and cardinality, stop it and ask for the data model first.
What does one row represent in every table?
What column should uniquely identify a row?
Is the key actually unique in the data?
What is the expected relationship between the tables?
Will this join increase the number of rows?
Should I aggregate before joining?
After the join, do totals still reconcile to the source?
The final analytical table should contain exactly one row per order because completed-order and cancellation metrics are order-level metrics.
Customers and cities can be joined directly only after confirming that their keys are unique.
Status events, payments and promotions should be reduced to one row per order before being joined to orders.
Compare row count, distinct order count and total gross value before and after the joins. Unexpected changes are a warning.
Before every join, say the relationship out loud: “many orders to one customer,” “one order to many status events.” That five-second habit prevents a surprising amount of bad analytics.
Join order-level revenue to item-level rows and watch a perfectly valid SUM become wrong.