Why SUM(DISTINCT amount) Is Not a General Fix for JOIN Double Counting

Learn why SUM(DISTINCT amount) can discard valid payments, how SQL deduplicates rows, and when to use payment identity, EXISTS, or explicit allocation.

A payment report can be wrong even when each table and foreign key is correct. The usual clue is a JOIN that multiplies rows before SUM runs.

This example uses one order, three payments, and two tags. The payments total 180, but joining the tags creates six rows: a plain SUM returns 360 and SUM(DISTINCT amount) returns 130.

The goal is to keep two valid 50 payments, remove only the JOIN-generated copies, and make the counted fact explicit. The examples compare identity-based deduplication with EXISTS, then separate those query problems from duplicate ingestion and allocation.

Build the Smallest Reproducible Case

Start with one order and three payment records:

Payment IDOrder IDAmount
101150.00
102150.00
103180.00

Payments 101 and 102 are separate transactions. Their amounts happen to match.

The correct payment total is:

50 + 50 + 80 = 180

The order also has two tags: MEMBER and COURSE. We want to total payments for orders that have at least one tag.

Run the SQL below in a fresh disposable database. It uses MySQL-compatible syntax and does not drop existing tables. Keep the setup and extra-order inserts separate so the fixture can be rerun cleanly.

-- Use a fresh disposable database. No existing tables are dropped.
CREATE TABLE sd_orders (
    id BIGINT PRIMARY KEY
);
CREATE TABLE sd_payments (
    id BIGINT PRIMARY KEY,
    order_id BIGINT NOT NULL,
    amount DECIMAL(12,2) NOT NULL,
    FOREIGN KEY (order_id) REFERENCES sd_orders(id)
);
CREATE TABLE sd_order_tags (
    id BIGINT PRIMARY KEY,
    order_id BIGINT NOT NULL,
    tag_code VARCHAR(30) NOT NULL,
    FOREIGN KEY (order_id) REFERENCES sd_orders(id)
);
INSERT INTO sd_orders VALUES (1);
INSERT INTO sd_payments VALUES
    (101, 1, 50.00), (102, 1, 50.00), (103, 1, 80.00);
INSERT INTO sd_order_tags VALUES
    (201, 1, 'MEMBER'), (202, 1, 'COURSE');

The fixture treats every payment as valid and uses one currency. Production reports need explicit status, refund, tenant, and time policies, but those are not necessary to demonstrate this error.

See What the JOIN Actually Produces

Join the payment records to the order’s tags:

SELECT p.id AS payment_id, p.order_id, p.amount,
       t.id AS tag_id
FROM sd_payments AS p
JOIN sd_order_tags AS t ON t.order_id = p.order_id
ORDER BY p.id, t.id;

The result contains six rows:

Payment IDOrder IDAmountTag ID
101150.00201
101150.00202
102150.00201
102150.00202
103180.00201
103180.00202

Each payment appears once for each tag. No payment record was duplicated in the source table; the query has produced multiple combinations involving it.

The distinction matters. We need to remove repeated representations of a payment from the monetary calculation, while retaining payments 101 and 102 as separate facts.

Now compare the two sums:

SELECT p.order_id,
       SUM(p.amount) AS joined_total,
       SUM(DISTINCT p.amount) AS distinct_value_total
FROM sd_payments AS p
JOIN sd_order_tags AS t ON t.order_id = p.order_id
GROUP BY p.order_id;

The output is:

Order IDjoined_totaldistinct_value_total
1360.00130.00

The plain sum adds all six rows:

50 + 50 + 50 + 50 + 80 + 80 = 360

The distinct-value sum first reduces the amounts to two values:

Distinct amounts: {50, 80}
Sum: 50 + 80 = 130

Neither answer is the required 180.

What SUM(DISTINCT) Knows—and What It Does Not

MySQL defines SUM(DISTINCT expression) as a sum of the distinct values of that expression. It does not use a payment ID unless that identity is part of some separate operation in the query. MySQL aggregate-function documentation

When the expression is amount, the aggregate sees amounts. It does not know whether two occurrences of 50 represent:

  • One payment repeated by a JOIN.
  • Two independent payments.
  • Duplicate ingestion of the same payment event.

Those distinctions require identity and business context.

SUM(DISTINCT) is not a broken function. It is correct when the requirement truly is to sum unique numeric values within each group. That is different from summing each valid payment once.

A dataset in which every payment amount happens to differ can hide the mistake. Tests need repeated legitimate values to expose it.

DISTINCT Applies to the Selected Row Shape

Switching to SELECT DISTINCT is not automatically enough. It removes duplicate result rows based on the selected expressions. Change the selected columns, and you change what counts as a duplicate. MySQL SELECT documentation

Selecting Only the Order and Amount Loses a Payment

Consider:

SELECT DISTINCT p.order_id, p.amount
FROM sd_payments AS p
JOIN sd_order_tags AS t ON t.order_id = p.order_id
ORDER BY p.order_id, p.amount;

The result is:

Order IDAmount
150.00
180.00

Payments 101 and 102 have collapsed into the same selected tuple: order 1, amount 50.

Summing this result still gives 130.

Including the Payment Identity Preserves Both Payments

Now include the payment’s primary key:

SELECT DISTINCT p.id AS payment_id, p.order_id, p.amount
FROM sd_payments AS p
JOIN sd_order_tags AS t ON t.order_id = p.order_id
ORDER BY p.id;

The result is:

Payment IDOrder IDAmount
101150.00
102150.00
103180.00

The two JOIN-generated copies of payment 101 become one row, as do the copies of 102 and 103.

The two distinct 50 payments survive because their payment IDs differ.

This works under the fixture’s assumptions: the ID uniquely identifies a payment, and the selected order and amount come from that payment record.

Including the Tag Identity Retains All Six Rows

Add tag_id to the projection:

SELECT DISTINCT p.id AS payment_id, p.order_id, p.amount,
       t.id AS tag_id
FROM sd_payments AS p
JOIN sd_order_tags AS t ON t.order_id = p.order_id
ORDER BY p.id, t.id;

All six rows remain.

The rows for payment 101 differ in tag ID. They are not duplicate selected rows.

This is why “just add DISTINCT” is an incomplete instruction. A reviewer needs to ask: distinct according to which columns, and does that combination identify the fact being counted?

Repair the Query by Preserving Payment Identity

If an existing joined result must be reduced to its unique payment set, select that set before summing:

SELECT x.order_id, SUM(x.amount) AS paid_amount
FROM (
    SELECT DISTINCT p.id AS payment_id, p.order_id, p.amount
    FROM sd_payments AS p
    JOIN sd_order_tags AS t ON t.order_id = p.order_id
) AS x
GROUP BY x.order_id
ORDER BY x.order_id;

The result is:

Order IDpaid_amount
1180.00

The inner query establishes one row per payment. The outer query aggregates those payments by order.

This is different from SUM(DISTINCT amount):

ApproachUnit retained onceResult
Sum joined amountsEvery joined row360
Sum distinct amountsEvery different numeric value130
Sum the distinct payment setEvery payment record180

Do not add unrelated child columns to the inner projection without reconsidering its grain.

Also avoid treating this as a universal pattern for any calculated amount. If the amount legitimately varies by allocation or line item, payment ID alone may not describe the fact you need to retain.

Prefer EXISTS When Tags Only Determine Eligibility

For this specific requirement, there is an even clearer formulation.

We only need to know whether an order has any tag. We do not need tag records in the monetary result:

SELECT p.order_id, SUM(p.amount) AS paid_amount
FROM sd_payments AS p
WHERE EXISTS (
    SELECT 1
    FROM sd_order_tags AS t
    WHERE t.order_id = p.order_id
)
GROUP BY p.order_id
ORDER BY p.order_id;

This also returns 180.

EXISTS checks for a matching record without adding tag rows to the outer payment set. MySQL EXISTS documentation

The comparison is about expressing the requirement clearly. No performance advantage is claimed without measuring the relevant workload.

Both this query and the preceding identity-based query intentionally exclude payments for untagged orders. To make that behavior visible, add:

-- Run once after the initial examples.
-- This order has a valid payment but no tags.
INSERT INTO sd_orders VALUES (2);
INSERT INTO sd_payments VALUES (104, 2, 25.00);

Rerun either tagged-order query. Order 2 does not appear.

If the actual requirement is all orders, tags should not determine eligibility. For example:

SELECT o.id AS order_id,
       COALESCE(p.paid_amount, 0) AS paid_amount
FROM sd_orders AS o
LEFT JOIN (
    SELECT order_id, SUM(amount) AS paid_amount
    FROM sd_payments
    GROUP BY order_id
) AS p ON p.order_id = o.id
ORDER BY o.id;

The result includes order 1 with 180 and order 2 with 25. An order without payment records would remain with a displayed zero.

A rewrite that produces the desired total for one order may still change which orders are included. Verify membership as well as amounts.

Three Cases That Look Like Duplicates

The phrase “duplicate payment” can describe several situations.

SituationExampleAppropriate response
Repeated representation from a JOINPayment 101 appears for two tagsAdjust query grain, use EXISTS, or recover the payment set
Duplicate ingestion of a business eventOne channel transaction becomes two database rowsEstablish business identity and enforce ingestion idempotency
Legitimate equal valuesPayments 101 and 102 are both 50Keep both

These are not interchangeable problems.

In the first case, the source table can be perfectly correct. The query must avoid counting the same fact repeatedly.

In the second case, distinct database IDs do not prove that the underlying business transactions differ.

In the third case, there is nothing to deduplicate.

Database Identity Is Not Always Business Identity

Imagine the payment table contains:

Row IDMerchantChannelTransaction referenceAmount
5017CARDtxn-abc50.00
5027CARDtxn-abc50.00

Assume these rows are accidental repeated ingestion of one successful capture.

Selecting DISTINCT id, amount retains both rows and totals 100. It cannot recover the intended 50 because the physical record IDs differ.

The solution requires a business identifier whose uniqueness is defined by the payment provider and the application. It might involve merchant, channel, transaction reference, and event type.

Do not assume a transaction reference is globally unique. Do not collapse separate refunds, captures, or lifecycle events merely because they reference the same payment.

Once the identity contract is established, enforce it with an appropriate ingestion process and database constraint. Existing duplicates require investigation and controlled correction, especially if downstream balances or commissions already include them.

A reporting query that silently chooses one conflicting record does not repair the source data.

Some Relationships Need Allocation, Not Deduplication

Suppose a 100 payment covers two services.

If a report needs revenue by service, assigning 100 to each service counts 200. Keeping the payment under only one service loses the other service’s attribution.

Neither amount-based nor payment-ID-based deduplication defines the correct answer.

The system needs an allocation rule and corresponding detail, for example:

Payment IDServiceAllocated amount
701A60.00
701B40.00

The allocation total remains 100, while each service receives its share.

The relevant grain is now a payment allocation, not merely a payment.

Deduplication removes repeated representations of a fact. Allocation determines how a fact’s value belongs to multiple reporting objects.

Before editing SQL, decide which problem the report actually has.

Choose the Query Shape from the Output Requirement

A useful decision table is:

RequirementStarting approach
Sum payments for orders meeting a child-table conditionEXISTS
Show payment totals alongside other child-table totalsAggregate each child to the shared reporting grain
Recover unique payments from an unavoidable joined resultSelect payment identity and stable payment attributes before summing
Correct repeated ingestion of one business transactionBusiness-level idempotency and source-data correction
Attribute a payment across several servicesExplicit allocation records and rules
Sum unique numeric valuesSUM(DISTINCT expression)

If a report combines several child tables, see Why Your SQL SUM Is Too High: Fixing Double Counting in One-to-Many JOINs. That article focuses on pre-aggregating each child table; this one focuses on identifying which fact should be counted.

There is no need to make the SQL more elaborate than the requirement demands. Avoid creating repeated rows merely to remove them later when an existence check can express the same condition.

Validate with Counterexamples

A test with three different payment amounts is insufficient. A faulty distinct-value sum could pass it.

The local verifier checks the following cases.

  • Three payments totaling 180, including two equal amounts.
  • Six rows created by the payment-to-tag join.
  • A plain joined sum of 360.
  • A distinct-value sum of 130.
  • Two rows when projecting only order and amount.
  • Three rows when projecting payment identity.
  • Six rows again when including tag identity.
  • Correct identity-based and EXISTS totals of 180.
  • An untagged order excluded from both tagged-order queries.
  • An all-order report that retains untagged and payment-free orders.
  • Two physical IDs that still represent one duplicated business transaction.

The local verifier checks the fixture with SQLite: six joined rows, totals of 360 and 130, and identity-based and EXISTS results of 180. It also checks the untagged order and duplicate business-reference case.

The allocation example is a conceptual design illustration, not an implemented allocation engine.

To reproduce manually, run 01-setup.sql once in a fresh database, then 02-08 in order. Run 09-extra-order.sql once, rerun 07 and 08, and finish with 10-all-orders.sql. Use a fresh database for another complete run.

verify.py creates an isolated in-memory SQLite database; it does not connect to or alter an existing database.

A Short Review Checklist

Before changing the aggregate, write down what the report is supposed to count:

  • a payment row;
  • a provider transaction;
  • an event;
  • or an allocation line.

Payment 101 repeated for two tags is a query-shape problem. Rows 501 and 502 with the same provider reference may be an ingestion problem. Payments 101 and 102 with the same amount are two valid facts. The correct response depends on which case the data represents.

Define the identity first, then choose between EXISTS, identity-based deduplication, idempotency, or allocation.

Download the Complete Examples

The ZIP archive contains the SQL files used in this walkthrough and verify.py. The verifier uses an isolated in-memory SQLite database; the SQL setup is written for a fresh MySQL-compatible database. Download examples.zip if you want to reproduce the checks locally.

Leave a Reply

Your email address will not be published. Required fields are marked *