Tutorial

SQL JOIN Fan-Out: What It Is, Why SUM Breaks & 2 Proven Fixes

Learn what SQL JOIN fan-out is, why one-to-many joins silently duplicate rows and break SUM/AVG totals, and how to fix it with pre-aggregation and DISTINCT.

Anuj SainiAug 23, 2026Updated Aug 24, 202610 min read

This is the single most dangerous SQL bug in analytics work. It does not crash your query. It does not throw a warning. It produces a number that looks completely reasonable — and is off by over a million dollars.

Every analyst who has joined tables and then aggregated has hit this trap. It is a classic interview question and a classic take-home failure. This guide walks through exactly how it happens, why, and the two fixes that prevent it — using a real e-commerce dataset where every number is verified against a live PostgreSQL database. For related SQL concepts, see our SQL NULL Guide and SQL CTE Guide.



1. How Joins Chain Across Tables

Before the trap, the mechanics. Joins chain: orders connect to order_items, and order_items connect to products. Three tables, two JOINs.

sql
SELECT o.order_id,
       p.product_name,
       p.category,
       oi.quantity,
       oi.unit_price
FROM orders AS o
JOIN order_items AS oi ON o.order_id    = oi.order_id
JOIN products    AS p  ON oi.product_id = p.product_id
LIMIT 10;

Each JOIN ... ON adds another table along its key. Now you can see every product inside every order. But notice what just happened to the grain of the data: one order can span several rows here — one per line item. That innocent fact is the whole trap.

The Table Relationships

orders (1) ──< order_items (many)    via order_id     [enforced FK]
products (1) ──< order_items (many)   via product_id   [enforced FK]

One order has many line items. One product appears in many line items. When you join orders to order_items, each order row is duplicated once per line item it contains.


2. The Trap, Live

Here is the true total order revenue, straight from the orders table:

sql
SELECT ROUND(SUM(total_amount), 2) AS revenue
FROM orders;

Result: 782,905.04

Now something that looks completely innocent — join in order_items (maybe to filter by product later), and sum the same column:

sql
SELECT ROUND(SUM(o.total_amount), 2) AS revenue
FROM orders AS o
JOIN order_items AS oi ON o.order_id = oi.order_id;

Result: 2,009,577.61

The same SUM, of the same column, on the same orders. It ballooned 2.57 times, just because of one join. No error. It just hands you a wrong answer. If you pasted that into a board deck, you would have overstated revenue by over 1.2 million dollars.

The Interview Favorite

Fan-out is a classic interview and take-home trap. Interviewers present a schema with one-to-many relationships and ask you to calculate revenue or totals. Candidates who join first and aggregate second produce inflated numbers. The correct approach is to aggregate first, then join.


3. Why It Happens: The Grain Change

Here is the mechanism. An order with several line items does not stay one row after the join. It becomes one row per line item, and total_amount is copied onto every one of them.

Proof: One Order, Repeated

sql
SELECT o.order_id, o.total_amount, oi.item_id
FROM orders AS o
JOIN order_items AS oi ON o.order_id = oi.order_id
WHERE o.order_id = 1;
order_idtotal_amountitem_id
1249.991
1249.992
1249.993
1249.994
1249.995

Order 1 has 5 line items. After the join, it occupies 5 rows — and total_amount (249.99) is copied onto every single one. When you SUM(total_amount), you count that order's total 5 times instead of once.

The orders "fanned out" across their line items, and the sum counted the duplicates. Any time you join a one-to-many relationship and then aggregate a value from the "one" side, you risk this.


4. Fix 1: Pre-Aggregate Before Joining

The real fix is to stop summing the order total across line items. Aggregate the orders first, ensuring one row per order, then join.

sql
-- The fix: pre-aggregate orders to one row each, THEN join
SELECT ROUND(SUM(o.total_amount), 2) AS revenue
FROM (SELECT order_id, total_amount FROM orders) AS o
JOIN (SELECT DISTINCT order_id FROM order_items) AS oi
  ON o.order_id = oi.order_id;

Result: 782,905.04 — back to the true order revenue.

The subquery SELECT DISTINCT order_id FROM order_items ensures each order appears only once. The orders table already has one row per order, so joining against a deduplicated list of order IDs preserves that grain. The total cannot fan out because there is no duplication.

The Rule of Thumb

Before you SUM after a join, ask: what is one row here, and am I double-counting?

If one row in your joined result represents something smaller than what you are summing (e.g., one row = one line item, but you are summing an order-level total), you have a fan-out problem.


5. Fix 2: COUNT(DISTINCT) for Headcounts

When you need a count rather than a sum, COUNT(DISTINCT order_id) sidesteps the duplication entirely:

sql
SELECT COUNT(DISTINCT o.order_id) AS unique_orders
FROM orders AS o
JOIN order_items AS oi ON o.order_id = oi.order_id;

Even though each order appears multiple times (once per line item), COUNT(DISTINCT) collapses them back to one. This is safe for headcounts, order counts, customer counts — any metric where you need the number of unique entities.

Feature / Criteria

6. The Three Numbers: Don't Confuse Them

There are three numbers people reach for after joining orders to order_items. Only one is "order revenue."

NumberValueWhat It MeansIs It the Fix?
Order revenue782,905.04SUM of orders.total_amountYes — the true total
Fan-out artifact2,009,577.61Same SUM after a one-to-many joinNo — this is garbage
Gross line-item value2,731,234.13SUM of quantity * unit_price from order_itemsNo — different metric

Gross Line-Item Value Is Not the Fix

sql
-- A DIFFERENT metric: gross line-item value, not order revenue
SELECT ROUND(SUM(oi.quantity * oi.unit_price), 2) AS line_value
FROM orders AS o
JOIN order_items AS oi ON o.order_id = oi.order_id;

Result: 2,731,234.13

This 2.7M is not the fan-out artifact, and it is not wrong — it just answers a different question. It is gross line-item value, not order revenue. And it has two caveats:

  1. It silently drops 169 line items with a NULL quantity (NULL * anything = NULL, which SUM skips).
  2. It ignores discounts (discount_percent exists on order_items but is not factored in).

So it is gross, not net. Three numbers, then: the 783K order total, the 2.0M fan-out artifact (which is garbage), and the 2.7M gross item value (which is a different metric). Only one of them is "the order revenue."


7. The Fan-Out Checklist

Before trusting any SUM or AVG after a join, run through this checklist:

  1. Identify the grain. What does one row represent in your joined result? If it is smaller than what you are aggregating, you have a problem.
  2. Check for one-to-many joins. Did you join a table that has multiple rows per key on the "one" side? That is where fan-out lives.
  3. Run a proof query. SELECT order_id, total_amount, item_id — if total_amount is repeated per item_id, you have duplication.
  4. Pre-aggregate or use DISTINCT. Roll the "one" side to one row per entity before joining, or use COUNT(DISTINCT) for headcounts.
  5. Compare to the unjoined total. If SUM before the join differs from SUM after the join, fan-out is the likely cause.

8. Fan-Out Beyond SUM: AVG Is Worse

SUM is the obvious victim, but AVG is more insidious because it does not just inflate — it reweights.

If an order with a large total_amount happens to have many line items, that order's total gets counted more times in the average. The average is no longer "the average order" — it is "the average line-item-weighted order," which is a meaningless metric.

sql
-- This AVG is wrong — orders with more line items are weighted more heavily
SELECT AVG(o.total_amount) AS avg_order
FROM orders AS o
JOIN order_items AS oi ON o.order_id = oi.order_id;

The fix is the same: pre-aggregate to one row per order before computing the average.


9. Practice Fan-Out on Real Databases

Recognizing fan-out is a skill. Fixing it under interview pressure requires practice. The best way to internalize it is to break a few SUMs on purpose and watch the numbers inflate.

Master SQL JOINs & Grain Control

Drill 190+ interactive SQL practice questions in our live PostgreSQL browser sandbox, or master end-to-end data warehousing in our Data Analyst Career Track.

Practice JOIN Problems Free

Frequently Asked Questions

What is fan-out in SQL joins?

Fan-out occurs when a one-to-many join duplicates rows from the 'one' side of the relationship. If an order has 5 line items, joining order_items to orders creates 5 rows for that order, each carrying the same total_amount. SUMming total_amount across those rows counts it 5 times instead of once.

How do you fix the fan-out trap in SQL?

Pre-aggregate the orders to one row each before joining, using a subquery with DISTINCT order_id from the line-item table. This ensures each order appears only once, so SUM returns the correct total. Alternatively, use COUNT(DISTINCT order_id) for headcounts to sidestep duplication entirely.

What is the difference between order revenue and gross line-item value?

Order revenue is the SUM of the total_amount column on the orders table (782,905.04 in our dataset). Gross line-item value is the SUM of quantity times unit_price from order_items (2,731,234.13). They answer different questions — one is order-level, the other is item-level. Neither is the fan-out artifact of 2,009,577.61.

Does fan-out only affect SUM?

Fan-out affects any aggregate that is sensitive to row count, including SUM, AVG, and COUNT. COUNT(DISTINCT column) is immune because it deduplicates. AVG is particularly dangerous because it weights rows from the 'one' side by how many line items they have.

How can I tell if my query has a fan-out problem?

Before aggregating after a join, ask: what is one row here? If one order spans multiple rows after the join, you have changed the grain. Run a proof query like SELECT order_id, total_amount, item_id to see if total_amount is repeated per line item. If it is, you risk double-counting.

Why is SQL join fan-out a classic interview question?

Interviewers frequently test join fan-out because it proves whether a candidate understands data grain and relational cardinality. Candidates who join first and aggregate second report inflated metrics, whereas experienced analysts aggregate first and join second.

Anuj Saini

Written by

Anuj SainiFounder & Lead Instructor

Founder at Topfolio with 6+ years in data & analytics across JPMC, Ultrahuman, and high-growth startups. Sat on hiring panels, reviewed 500+ resumes, and writes practical SQL & data guides.