Tier 5 · Expert

Join Fanout and Aggregate Correctness in SQL

By Owen Middleton · Updated September 2026 · Examples run on PostgreSQL 17

What is Join Fanout in SQL?

Join fanout is what happens when a join multiplies rows beyond what you intended, causing every aggregation that follows to compute over inflated data. The query runs without error, returns plausible-looking numbers, and gives no indication anything is wrong.

Here's the mechanism. You have an orders table and an order_items table, where each order has multiple line items. When you join them on order_id, you get one row per line item — not per order. An order with five items appears five times in the joined result. If you then sum orders.revenue (an order-level column), that revenue figure gets counted five times for the five-item order. The sum is five times too large, and nothing in the query or output flags the problem.

-- Fanout inflating revenue
SELECT
    o.customer_id,
    SUM(o.order_revenue) AS total_revenue  -- wrong: counted once per line item
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.customer_id
SELECT 'orders in the join'  AS source, COUNT(DISTINCT o.id) AS row_count
FROM orders o JOIN order_items oi ON oi.order_id = o.id
UNION ALL
SELECT 'rows after the join', COUNT(*)
FROM orders o JOIN order_items oi ON oi.order_id = o.id

Customers with more line items appear to have disproportionately higher revenue. The tell is in those two counts: 91 orders go into the join and 100 rows come out, so some orders are being counted more than once. Sum an order-level column over those 100 rows and the total overstates real revenue — 58,561.81 becomes 71,814.77.

How do you check whether a join is fanning out?

Diagnosing fanout

Before aggregating, check the row count at the join level:

SELECT COUNT(*) AS joined_rows, COUNT(DISTINCT o.id) AS orders_in_join
FROM orders o
JOIN order_items oi ON oi.order_id = o.id

If joined_rows exceeds orders_in_join, the join is fanning out. Compare the two numbers from the same join rather than comparing against SELECT COUNT(*) FROM orders: an inner join drops unmatched rows as well as multiplying matched ones, so the joined count can land *below* the size of orders while still fanning out. That is exactly what happens here. Fanout is expected behaviour for a one-to-many join — the problem is aggregating an order-level column over the multiplied rows.

Make this check a habit: verify the join row count before writing the aggregation. It's faster to catch fanout here than to trace wrong numbers back through the query after the fact.

How do you fix join fanout in SQL?

The fix: pre-aggregate before joining

Collapse the right-side table to one row per key before the join runs:

WITH item_totals AS (
    SELECT order_id, SUM(item_revenue) AS total_item_revenue
    FROM order_items
    GROUP BY order_id
)
SELECT
    o.customer_id,
    SUM(o.order_revenue) AS total_revenue
FROM orders o
JOIN item_totals it ON it.order_id = o.order_id
GROUP BY o.customer_id

Now the join is one-to-one on order_id. No fanout. The aggregation is correct.

What happens to fanout across two one-to-many joins?

The one thing that trips people up

Fanout compounds across multiple joins. If you join orders to both order_items (5 items) and order_shipments (2 shipments), you get 10 rows per order — one per item-shipment combination. Aggregating any order-level column inflates it by a factor of 10.

The precondition for avoiding fanout is knowing the cardinality of every join before writing it: for each join key, can the right side have more than one row per key value? If yes, aggregating anything from the left side over that join is dangerous unless the right side is pre-aggregated first.

Practice Join Fanout in SQL

Practice · easy ecommerce · Brightlane

Scenario: Brightlane's operations analyst is sizing the row volume of a planned line-item revenue report before committing to its compute cost.

Task: Write a query to return the total number of (order, line-item) pairings on record — each line item paired with its parent order.

Assumptions:

  • Every line item in order_items corresponds to exactly one parent order, and an order may have multiple line items.

Output:

  • One row, holding the total pairing count.
  • Columns in this order: joined_row_count.
Schema · ecommerce5 tables? = nullable
categories
idinteger
nametext
parent_id?integer
products
idinteger
nametext
category_id?integer
pricenumeric
stock_qtyinteger
attributes?jsonb
order_items
idinteger
order_id?integer
product_id?integer
quantityinteger
unit_pricenumeric
customers
idinteger
nametext
emailtext
city?text
countrytext
created_attimestamptz
is_activeboolean
orders
idinteger
customer_id?integer
ordered_attimestamptz
statustext
total_amountnumeric

Run previews · Check grades

Write a query, then run it to see results here.

Worked solution

The full breakdown walks through the shape, each clause, why this approach beats the alternatives, and the trap to avoid.

See the full worked solution

10 Join Fanout practice problems

Start learning to practice all 10 Join Fanout problems, with instant grading and mastery tracking.

Common questions about Join Fanout

Does fanout affect COUNT as well as SUM?

It affects anything measured on the one side of a one-to-many join. A plain count of rows counts the multiplied rows; counting distinct ids on the original table gives the honest figure and is a quick way to see whether the multiplication happened.

Does adding DISTINCT fix a fanned-out SUM?

No, and it makes things worse in a new way. Summing distinct values removes genuine repeats too, so two orders that happen to cost the same are counted once. The fix is to aggregate the many side down to one row per key before joining.

How do you spot fanout before it reaches a total?

Count the rows after the join and compare that with the count of the table you are measuring. If the join made more rows than the table has, every figure computed from the joined result is measured over duplicated data.

How you actually get good at SQL

Reading explains SQL. Writing it, over and over with instant feedback, is what makes you fluent.

That's the whole SQLMaxx loop: 600+ real problems, instant AI feedback, mastery you can actually see, and spaced review that won't let you forget.

A stack of SQL practice problem cards, the top card showing an employees table.
615 problems · 66 concepts

Real problems. Not toy examples.

615 hand-built problems spanning all 66 concepts, from basic SELECTs to window functions, built on real schemas and real business questions, the kind you'll actually get asked on the job. Enough reps to make SQL automatic.

A retro computer showing a SQL query marked correct with a green checkmark.
Instant AI feedback

Write a query. Know if it's right in one second.

No copying an answer and hoping it clicked. The AI grader checks your real query against real data, catches exactly what's wrong, and explains the fix in plain English, like a senior analyst reading over your shoulder on every problem.

A circular mastery progress dial filling from blue to green, the SQLMaxx diamond at its center.
Mastery tracking

Stop guessing whether you actually know it.

SQLMaxx tracks every concept and shows you what you've mastered and what's still shaky. Your skills fill in one concept at a time, so 'I think I get joins' becomes something you can prove.

A SQL query editor circled by a blue return arrow with a clock, scheduled to come back for review.
Spaced review

Learn it once. Keep it for good.

Most of what you learn this week fades by next week. So when a concept comes due for review, SQLMaxx hands you a fresh problem to solve from a blank editor, not a flashcard to re-read. A research-backed spaced-repetition algorithm (FSRS) times each return for right before you'd forget, so your SQL is still there months later, when the interview or the job actually needs it.