When joins multiply, can you trust the total?
A query can run perfectly and still overstate your numbers. Learn how joining orders, order lines and payments changes the detail behind a total—and how to put it right.
25 September 2026 · SQL Server / Azure SQL Database · For analysts and reporting teams · Basic SELECT and JOIN knowledge assumed.
The reconciliation desk
The business risk is a believable wrong answer
An inflated order-value report can distort a sales review, a forecast or a customer ranking. Because the query succeeds, the problem may survive until somebody reconciles the output to its source.
Define what one row means
This is the table’s grain. An order header has one row per order; order lines have one row per line; payments have one row per payment. Those are different units of observation.
Recognise valid repetition
Two lines and two payments for one order are not necessarily bad source data. Joining both child tables only by OrderId creates four matching combinations for that order.
Protect each measure
The header value repeats across those combinations. Line and payment amounts can repeat too. A correct distinct order count does not prove that the monetary totals are correct.
Three orders. Two child tables. One multiplication trap.
All amounts below are GBP on the same simplified value basis. There are no taxes, refunds, cancellations or currency conversions in this fixture. Order value is not a revenue-recognition measure.
| Order | Header value | Order lines | Payments | Rows after both LEFT JOINs |
|---|---|---|---|---|
| 101 | £100 | £60 + £40 | £50 + £50 | 4 |
| 102 | £100 | £100 | £100 | 1 |
| 103 | £60 | £60 | None recorded | 1, with a NULL payment |
For order 101, each of its two lines matches each of its two payments. Summing the repeated header contributes £400 instead of £100. The other orders contribute £160, taking the incorrect total to £560.
Reproduce the error, then rebuild at order grain
Run this entire block as one batch in a SQL Server or Azure SQL Database query window. It uses local table variables, creates no permanent tables and changes no business records. Do not insert GO separators.
-- 1. Create a small, controlled fixture.
DECLARE @Orders TABLE (
OrderId int PRIMARY KEY,
OrderValue decimal(12,2) NOT NULL
);
DECLARE @Lines TABLE (
LineId int PRIMARY KEY,
OrderId int NOT NULL,
LineValue decimal(12,2) NOT NULL
);
DECLARE @Payments TABLE (
PaymentId int PRIMARY KEY,
OrderId int NOT NULL,
PaidValue decimal(12,2) NOT NULL
);
INSERT INTO @Orders VALUES (101, 100), (102, 100), (103, 60);
INSERT INTO @Lines VALUES
(1, 101, 60), (2, 101, 40), (3, 102, 100), (4, 103, 60);
INSERT INTO @Payments VALUES
(1, 101, 50), (2, 101, 50), (3, 102, 100);
-- 2. Compare the source with the unsafe join.
SELECT SUM(OrderValue) AS SourceOrderValue
FROM @Orders;
-- Expected: 260.00
SELECT
COUNT(*) AS JoinedRows,
SUM(o.OrderValue) AS InflatedOrderValue,
SUM(DISTINCT o.OrderValue) AS DistinctAmountTrap
FROM @Orders AS o
LEFT JOIN @Lines AS l ON l.OrderId = o.OrderId
LEFT JOIN @Payments AS p ON p.OrderId = o.OrderId;
-- Expected: 6 rows; 560.00; 160.00
-- 3. Locate orders that multiply in this joined result.
SELECT o.OrderId, COUNT(*) AS JoinedRows
FROM @Orders AS o
LEFT JOIN @Lines AS l ON l.OrderId = o.OrderId
LEFT JOIN @Payments AS p ON p.OrderId = o.OrderId
GROUP BY o.OrderId
HAVING COUNT(*) > 1;
-- Expected: OrderId 101, JoinedRows 4
-- 4. Aggregate each child independently, then join.
DECLARE @Report TABLE (
OrderId int PRIMARY KEY,
OrderValue decimal(12,2) NOT NULL,
LineTotal decimal(38,2) NULL,
PaidTotal decimal(38,2) NOT NULL
);
;WITH LineTotals AS (
SELECT OrderId, SUM(LineValue) AS LineTotal
FROM @Lines
GROUP BY OrderId
),
PaymentTotals AS (
SELECT OrderId, SUM(PaidValue) AS PaidTotal
FROM @Payments
GROUP BY OrderId
)
INSERT INTO @Report (OrderId, OrderValue, LineTotal, PaidTotal)
SELECT
o.OrderId,
o.OrderValue,
l.LineTotal,
COALESCE(p.PaidTotal, 0)
FROM @Orders AS o
LEFT JOIN LineTotals AS l ON l.OrderId = o.OrderId
LEFT JOIN PaymentTotals AS p ON p.OrderId = o.OrderId;
SELECT OrderId, OrderValue, LineTotal, PaidTotal
FROM @Report
ORDER BY OrderId;
SELECT
COUNT(*) AS ReportOrders,
SUM(OrderValue) AS CorrectOrderValue,
SUM(LineTotal) AS CorrectLineValue,
SUM(PaidTotal) AS CorrectPaidValue
FROM @Report;
-- Expected: 3 orders; 260.00; 260.00; 200.00
-- 5. Reconcile both coverage and values.
SELECT
SUM(CASE WHEN LineTotal IS NULL THEN 1 ELSE 0 END)
AS OrdersWithoutLines,
SUM(CASE WHEN LineTotal IS NOT NULL
AND LineTotal <> OrderValue THEN 1 ELSE 0 END)
AS OrdersWithLineMismatch
FROM @Report;
-- Expected: 0; 0
-- 6. If you only need a yes/no match, use EXISTS.
SELECT SUM(o.OrderValue) AS ValueOfOrdersWithPayments
FROM @Orders AS o
WHERE EXISTS (
SELECT 1
FROM @Payments AS p
WHERE p.OrderId = o.OrderId
);
-- Expected: 200.00; this is order value, not cash received.
Inspect the multiplication
The diagnostic identifies repeated orders in the joined result. Repetition is an error here because the required output is one row per order; it can be correct in a deliberately line-level report.
Summarise before combining
Each grouped child result contains at most one row per OrderId. Joining those results to a unique order key preserves the order grain. The report primary key also rejects repeated order identifiers.
Keep missingness meaningful
No payment rows means £0 recorded payments in this fixture. A missing line total stays NULL so the control can flag it. Converting every missing value to zero would hide useful evidence.
Know what the corrected report should return
The output preserves both £100 orders and keeps the order with no payment. These are the boundaries a quick “looks reasonable” check can miss.
| OrderId | OrderValue | LineTotal | PaidTotal |
|---|---|---|---|
| 101 | £100.00 | £100.00 | £100.00 |
| 102 | £100.00 | £100.00 | £100.00 |
| 103 | £60.00 | £60.00 | £0.00 |
| Total | £260.00 | £260.00 | £200.00 |
The final EXISTS query answers “What is the value of orders with at least one recorded payment?” It does not answer “How much cash have we received?” Those happen to be £200 each in the original fixture; a partial-payment test exposes the difference.
Test the cases that expose a false fix
Make each change independently in the fixture, then rerun the complete batch.
| Test | Expected result | What it proves |
|---|---|---|
| Keep both £100 orders | Order total remains £260, not £160. | Equal amounts are separate business facts. |
| Replace order 101’s two £50 payments with four £25 payments, using unique PaymentIds | Correct totals remain £260 orders and £200 paid; raw joined rows rise to 10. | Payment count must not change order value. |
| Remove one £50 payment from order 101 | Paid total becomes £150; value of orders with payments stays £200. | Existence is different from payment amount. |
| Remove order 103’s £60 line | Order total stays £260; line total becomes £200; missing-line count becomes 1. | Missing detail is visible rather than silently dropped. |
| Add a second order header with OrderId 101 | The primary key rejects the insert. | The order identifier must be unique. |
Make the production model explicit
A corrected join solves row multiplication. Reliable reporting also needs agreed definitions, source coverage and ownership.
Agree the population
Define which orders belong in the report and whether payments mean all recorded payments for those orders or receipts within a reporting period. Apply that decision consistently inside the relevant source queries.
Preserve the real key
If OrderId is unique only within a company, group and join by both CompanyId and OrderId. Decide how currencies, revisions, cancelled orders, refunds, tax and delivery charges affect each measure.
Respect lower-level analysis
An order-level payment cannot simply be repeated against every product line and summed by product. Product reporting needs a documented allocation rule or a separate payment-level view.
Source quality still matters: aggregating a duplicated payment record still counts it twice. Check transaction identifiers and investigate child records with no matching parent. Do not use aggregation as a substitute for source reconciliation.
Performance follows correctness: this tiny table-variable example teaches the logic. For production volumes, review execution plans, indexes on join/filter keys and the cost of the grouped queries with your database team.
Turn a query fix into a reporting control
Start with one important report and retain evidence that another analyst can repeat.
Document the grain
Record the business key, source tables, filters, intended row count and measure definitions alongside the query. Keep the small test fixture under version control with the reporting logic.
Approve the reconciliation
Compare order counts and values against an agreed source for the same population and cut-off. Sample individual orders, including no-payment and multiple-payment cases. Record the decision.
Monitor exceptions
Track duplicate report keys, missing child coverage, orphan records and reconciliation differences. Agree who investigates each exception and whether publication should stop until it is resolved.
Is your joined report ready for review?
Select only checks you can support with evidence. This checklist is a discussion aid, not a certification.
Frequently asked questions
Does LEFT JOIN prevent double counting?
No. LEFT JOIN keeps unmatched rows from the left side, but it still returns multiple matching combinations. It does not make the right-side key unique.
Why not use SUM(DISTINCT OrderValue)?
SUM(DISTINCT) sums unique numeric values, not unique orders. Two different orders with the same value would contribute that amount only once.
When should I use EXISTS?
Use EXISTS when you need to test whether a matching record exists without adding its detail rows to the result. It does not calculate the matching records’ amounts.
Does pre-aggregation remove source duplicates?
No. Pre-aggregation sums the rows it receives. Duplicate source transactions still need a defined detection and correction process.
Can I use the same SQL unchanged in every database?
No. This complete script targets SQL Server and Azure SQL Database and uses T-SQL table variables. Other SQL engines may require different fixture syntax, types or execution steps.
Can I analyse order-level payments by product?
Only with an appropriate model. An order-level payment needs a documented allocation rule before it can be summed by product, or it should remain in a separate payment-level analysis.
Make every total traceable to its source
Smart Statistics helps UK organisations improve SQL reporting, data quality and business intelligence. Build a reporting process with clear definitions, repeatable checks and evidence your decision-makers can trust.