Data AnalyticsSQL foundations for reliable analysis

SQL row grain: stop double-counting orders before you query

PK
Pankit Kumar
Sr. Data Scientist at Parexel (a Goldman Sachs–backed company) · 20 September 2026 · 5 min read
Technically reviewed by Ishaan Sharma
In this article (6 sections)

A SQL row's grain is the real-world thing that one row represents. An order table may contain one row per order; an order-item table contains one row per item line. Joining those tables changes the rows you are adding. If you sum an order-level amount after expanding it to item-level rows, you can count the same money several times.

This is why a query can run without errors, produce a sensible-looking dashboard and still be wrong. SQL checks whether the operation is valid. It does not know which business quantity you intended to measure.

We will reproduce the problem with eight completed orders, then fix it in two ways. The commerce SQL lab contains original synthetic CSV files, an executable Python/SQLite runner and reference results. No database server or real customer data is required.

State the metric before joining anything

Our question is: What is the recorded order value of completed orders, before refunds?

The contract is deliberately precise:

  • Count each eligible order once.
  • Include only orders whose status is completed.
  • Use order_total_paise, already recorded after any discount.
  • Report refunds separately; do not subtract them from this metric.
  • Retain an order even if its customer record is missing, and flag that issue separately.

Amounts are integer paise. A result of 104,000 paise means ₹1,040. This is an exercise metric, not an accounting policy for recognizing revenue.

The relevant grains are:

TableOne row representsUnique key
ordersOne order headerorder_id
order_itemsOne item line within an orderline_id

order_id is unique in the first table and intentionally repeated in the second. Two item lines do not mean two orders.

Establish a reference total

Start with the table that already has the grain your question requires:

sql
SELECT
    COUNT(*) AS completed_orders,
    SUM(order_total_paise) AS completed_value_paise
FROM orders
WHERE status = 'completed';

The verified result is eight orders and 104,000 paise. Save both values. A row count is a useful companion to a monetary total because it can reveal how a later join changes the population.

Now suppose someone adds item details to investigate product performance:

sql
SELECT
    COUNT(*) AS joined_rows,
    SUM(o.order_total_paise) AS reported_value_paise
FROM orders AS o
JOIN order_items AS i ON i.order_id = o.order_id
WHERE o.status = 'completed';

The query returns 12 joined rows and 171,000 paise. The business did not earn extra money. Several order headers were repeated once for each matching item line.

Order O1001 makes the mechanism visible. Its header contains 12,000 paise, and it has two item lines. After the join, 12,000 appears twice. Adding that header column contributes 24,000, although the order is still worth 12,000.

The same problem can arise with shipments, payments, support tickets or refund events. It is a relationship problem, not a special property of sales tables.

Fix the calculation at the appropriate grain

If you only need total order value, use the original order-level query. A join that contributes no required information adds risk without helping answer the question.

If you genuinely need a product-level result, use an amount defined at item-line grain:

sql
SELECT SUM(i.line_total_paise) AS completed_value_paise
FROM orders AS o
JOIN order_items AS i ON i.order_id = o.order_id
WHERE o.status = 'completed';

This returns 104,000 paise. In this fixture, item totals already include their allocated discounts and reconcile with the header. You must verify that assumption in a real system. Shipping, tax or order-level discounts may be stored only on the header, so line totals are not automatically interchangeable with order totals.

When you need item statistics next to each order, aggregate the many-side first:

sql
WITH item_summary AS (
    SELECT order_id, COUNT(*) AS line_count
    FROM order_items
    GROUP BY order_id
)
SELECT o.order_id, o.order_total_paise, i.line_count
FROM orders AS o
LEFT JOIN item_summary AS i ON i.order_id = o.order_id
WHERE o.status = 'completed';

item_summary has at most one row per order. That preserves the order grain. The LEFT JOIN also keeps an eligible order if its item records are unexpectedly absent; missing line counts can then be investigated rather than silently excluded.

Why DISTINCT is not a general repair

SUM(DISTINCT order_total_paise) deduplicates amount values, not orders. Different orders can legitimately have the same value. In our fixture, two orders are worth 12,000 paise and two are worth 10,000. A distinct sum produces 82,000 paise, which is another incorrect answer.

The repair is to define the entity and its key. It is not to remove repeated-looking numbers until a total appears plausible. SQLite documents joins and distinct processing separately; understanding that distinction makes these errors easier to diagnose. SQLite SELECT reference.

Make reconciliation part of the deliverable

Run the lab verifier:

powershell
python 11-Blog-Programme/labs/commerce-sql/build_and_verify.py

It checks the correct total, the deliberately inflated total and the incorrect distinct sum. It also changes one header amount and confirms that an independent line-to-header reconciliation detects the mismatch.

For your own analysis, write down the input grain, output grain, join cardinality and expected population. Check unique keys before the join, unmatched records after it, and at least one independently calculated control total. A successful query execution is only the beginning of validation.

Practice: add a third item line to an order while keeping the header unchanged. Decide whether the item total should be reallocated or whether the new line is a data error. Your answer should depend on the metric contract, not on whichever number your query happens to return.

NeuraPath's Data Analytics with Generative AI programme covers the SQL, data-wrangling and dashboard foundations needed to carry this reasoning across an analytical pipeline. The useful skill is being able to explain why a number is trustworthy before using it in a decision.

Continue learning

This article is part of the SQL foundations for reliable analysis sequence. Use the neighbouring tasks when you need the prerequisite or the next application.

PK
Pankit Kumar
Lead Instructor, NeuraPath Academy

Pankit Kumar has 10 years in Data Science & AI, building and shipping production systems in regulated pharma and clinical environments. He is a freelance trainer at Boston Institute of Analytics, AnalytixLabs and Scaler, and has taught this material to thousands of working professionals.

This article is part of our Data Analytics with Generative AI programme — 3–4 months. The full analyst stack — Excel, SQL, Power BI and Python pipelines — then a generative-AI layer you can prove is right.

Explore Data Analytics with Generative AI
Counselling is free · no obligation

Not sure which programme fits?

Tell us your background and we will map it to the right entry point — including saying so when a cheaper programme is the better fit. A counsellor replies within one working day.