In a learning report, a shop has two orders, but adding the order-item table unexpectedly increases the total amount. Nobody changed the data, there are no additional payments, and the query runs without an error. The cause may be that SQL JOIN repeats an order row for every matching item. Summing the order amount afterwards counts it multiple times.
This is not a client case or a financial report: all numbers below are illustrative. The example teaches you to identify a granularity error before using a result in an administration panel. It does not update tables, delete records or contact a production database. Work in a local PostgreSQL test environment through an already configured SQL client; no additional packages are required for these queries.
Agree on what one row represents
Start by defining the unit of accounting rather than repairing SUM. An order-table row usually represents an order, while an item-table row represents an individual item line. One order having several item lines is normal. Expecting each order to remain represented by exactly one row after such a join is not. Record the result's intended granularity next to the query.
An order amount and an item-line amount are different fields with different rules. The overall amount may include delivery, discounts or other components, so it cannot be replaced with a sum of items without agreement. First clarify the measure's purpose: order value, product value or payments actually received. Correct arithmetic does not repair an incorrect metric definition.
For our check, take two orders of 100 illustrative units each. The first has two item lines and the second has one. The expected order total is 200. A direct join produces three rows, and a mechanical SUM gives 300. The two orders deliberately have equal amounts: this helps demonstrate why the popular DISTINCT fix can also be wrong.
Reproduce the error without real tables
Paste the following query into a new SQL-client tab connected to a test PostgreSQL instance. The working directory is irrelevant: the query runs in the SQL editor, not the system terminal. The names orders and items belong to temporary WITH expressions here and do not read physical tables with those names. VALUES supplies only learning rows within the current query; permission to create tables is unnecessary.
WITH orders(id, total) AS (
VALUES (1, 100), (2, 100)
), items(order_id) AS (
VALUES (1), (1), (2)
)
SELECT
(SELECT SUM(o.total)
FROM orders o JOIN items i ON i.order_id = o.id) AS joined_total,
(SELECT SUM(total) FROM orders) AS actual_total,
(SELECT SUM(DISTINCT o.total)
FROM orders o JOIN items i ON i.order_id = o.id) AS distinct_total;
The expected results are 300 for joined_total, 200 for actual_total and 100 for distinct_total. This is a calculation for the supplied example, not a measurement of your shop. The first column repeats the first order's amount, while the third keeps just one unique numeric value. DISTINCT distinguishes values, not business objects: two different orders with equal amounts must not become one.
If your results differ, check that you executed the whole query and did not modify the learning rows. Do not add status or date restrictions before reproducing the basic error. A real report needs such restrictions, but during the exercise they can hide an incorrect join. You can check JOIN and WITH expression behaviour against the official PostgreSQL documentation.
Restore the granularity before joining
When you need both an order amount and its item-line count, group the items by order_id first. The right side of the join will then have at most one row per order. Next, use LEFT JOIN to preserve orders without items too. In this learning query, count means the number of item rows, not the quantity of product units: no quantity field is supplied here.
WITH orders(id, total) AS (
VALUES (1, 100), (2, 100)
), items(order_id) AS (
VALUES (1), (1), (2)
), item_counts AS (
SELECT order_id, COUNT(*) AS item_rows
FROM items
GROUP BY order_id
)
SELECT o.id, o.total, COALESCE(i.item_rows, 0) AS item_rows
FROM orders o
LEFT JOIN item_counts i ON i.order_id = o.id
ORDER BY o.id;
The result should contain two rows: item_rows is 2 for the first order and 1 for the second; total remains 100 in each. Summing total at this level no longer multiplies it by the item count. COALESCE handles an absent matching group by showing the count as zero. Do not automatically apply that rule to money, where NULL may mean an unknown value.
If items are needed only as a selection condition, consider EXISTS instead of joining all their rows. If item data is not used at all, remove the unnecessary JOIN after checking requirements. Not every join is dangerous: the problem arises when the number of matches is inconsistent with the measure. Check each subsequent relationship separately, including payments, delivery or the status history.
Make the check part of report changes
Add an order without items, an order with many items and two orders with equal amounts to your test set. Check both the number of orders and the total before and after each JOIN. Do not compare only the overall sum: accidental inflation and a missing order can sometimes offset one another. For several specific identifiers, inspect the rows produced by the join as well.
Before moving the query into the working report, preserve its previous version in Git and review the changes. Even a read-only query can be expensive on a large database, so start with a test copy and a restricted period. If you change the metric definition, explicitly inform report users. To roll back, restore the previous query version without changing or clearing historical orders.
Conclusion: a correct SQL query must preserve a measure's meaning, not merely return a plausible number. Defined granularity, control of matching-row counts and a small set of learning tests help identify multiplied sums. If an administration report shows inconsistent results, AriZone can help check the query logic and prepare a safe fix on a test copy.
Leave a comment