{"id":"l-sql-2-3","courseId":"sql-for-analysts","lang":"en","kind":"lesson","summary":"Recognise when a join multiplies rows, explain why sums over the repeated side inflate, and fix it by aggregating each table to the right grain before joining.","html":"<p>Karim in Finance sends a quick one: “For 2025, by channel, how much did we collect in shipping fees, and how much item revenue?” You already know the join. Here’s the query most people write first:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"sql\" data-run=\"sql\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">SQL</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"sql\" data-index=\"0\" aria-label=\"Run this example\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>Run</span></button><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">channel</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  ROUND</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">SUM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">shipping_fee</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">), </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> shipping,</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  ROUND</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">SUM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">oi</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">quantity</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> *</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> oi</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">unit_price</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> *</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">1</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> -</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> oi</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">discount</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)), </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> item_revenue</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> orders </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> o</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">JOIN</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_items </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> oi </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">ON</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> oi</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_id</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">status</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> &#x3C;></span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'cancelled'</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  AND</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_date</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> >=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '2025-01-01'</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  AND</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_date</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> &#x3C;</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">  '2026-01-01'</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">GROUP BY</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">channel</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span></code></pre><div class=\"run-output\" data-run-output=\"0\" hidden></div></div>\n<p>Item revenue is right. Shipping is about double what it should be: web shows 6,732 when the real figure is 3,413. Nothing errored, and nothing looks absurd at a glance. This is <strong>fan-out</strong>, the most expensive bug in analytics, because it produces believable numbers.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">Where the extra money comes from</h2>\n<p><code dir=\"ltr\">orders</code> and <code dir=\"ltr\">order_items</code> have a one-to-many relationship: one order, one or more lines. When you join them, the result has one row per <em>line</em>, and each line carries a copy of its order’s columns, <code dir=\"ltr\">shipping_fee</code> included.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 720 230\" role=\"img\" aria-labelledby=\"l-sql-2-3-t1\"><defs><marker id=\"arrow-l-sql-2-3-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-sql-2-3-t1\">Order 1001 with a shipping fee of 7.99 joins to four item lines. The joined result has four rows, each showing 7.99, so summing the fee gives 31.96 instead of 7.99.</title>\n  <text class=\"d-label-strong\" x=\"110\" y=\"28\" text-anchor=\"middle\">orders (one)</text>\n  <rect class=\"d-box-primary\" x=\"30\" y=\"90\" width=\"160\" height=\"44\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"110\" y=\"117\" text-anchor=\"middle\">1001  fee 7.99</text>\n  <text class=\"d-label-strong\" x=\"350\" y=\"28\" text-anchor=\"middle\">order_items (many)</text>\n  <rect class=\"d-box\" x=\"280\" y=\"40\" width=\"140\" height=\"32\" rx=\"6\"/>\n  <text class=\"d-code\" x=\"350\" y=\"61\" text-anchor=\"middle\">line 2</text>\n  <rect class=\"d-box\" x=\"280\" y=\"80\" width=\"140\" height=\"32\" rx=\"6\"/>\n  <text class=\"d-code\" x=\"350\" y=\"101\" text-anchor=\"middle\">line 3</text>\n  <rect class=\"d-box\" x=\"280\" y=\"120\" width=\"140\" height=\"32\" rx=\"6\"/>\n  <text class=\"d-code\" x=\"350\" y=\"141\" text-anchor=\"middle\">line 4</text>\n  <rect class=\"d-box\" x=\"280\" y=\"160\" width=\"140\" height=\"32\" rx=\"6\"/>\n  <text class=\"d-code\" x=\"350\" y=\"181\" text-anchor=\"middle\">line 5</text>\n  <path class=\"d-line\" d=\"M190 112 L280 56\"/>\n  <path class=\"d-line\" d=\"M190 112 L280 96\"/>\n  <path class=\"d-line\" d=\"M190 112 L280 136\"/>\n  <path class=\"d-line\" d=\"M190 112 L280 176\"/>\n  <text class=\"d-label-strong\" x=\"590\" y=\"28\" text-anchor=\"middle\">joined rows</text>\n  <rect class=\"d-box-warn\" x=\"500\" y=\"40\" width=\"180\" height=\"32\" rx=\"6\"/>\n  <text class=\"d-code\" x=\"590\" y=\"61\" text-anchor=\"middle\">1001 7.99 line 2</text>\n  <rect class=\"d-box-warn\" x=\"500\" y=\"80\" width=\"180\" height=\"32\" rx=\"6\"/>\n  <text class=\"d-code\" x=\"590\" y=\"101\" text-anchor=\"middle\">1001 7.99 line 3</text>\n  <rect class=\"d-box-warn\" x=\"500\" y=\"120\" width=\"180\" height=\"32\" rx=\"6\"/>\n  <text class=\"d-code\" x=\"590\" y=\"141\" text-anchor=\"middle\">1001 7.99 line 4</text>\n  <rect class=\"d-box-warn\" x=\"500\" y=\"160\" width=\"180\" height=\"32\" rx=\"6\"/>\n  <text class=\"d-code\" x=\"590\" y=\"181\" text-anchor=\"middle\">1001 7.99 line 5</text>\n  <text class=\"d-label\" x=\"590\" y=\"220\" text-anchor=\"middle\">SUM(fee) = 31.96</text>\n  <path class=\"d-arrow\" d=\"M425 116 L495 116\" marker-end=\"url(#arrow-l-sql-2-3-1)\"/>\n</svg><figcaption>Joining one order to four items repeats the order’s fee four times</figcaption></figure>\n<p>Item revenue survived because it lives on the many side: each line’s revenue appears exactly once. Shipping lives on the one side, so it’s counted once per line. Cartwheel orders average 1.93 lines, which is why shipping roughly doubled.</p>\n<p>The general rule: <strong>a join changes the grain to the grain of its most detailed table, and any number stored at a coarser grain gets repeated.</strong></p>\n<h2 id=\"sec-2\" tabindex=\"-1\">The fix: aggregate first, then join</h2>\n<p>Bring each table to the grain of the answer <em>before</em> joining. Here, collapse <code dir=\"ltr\">order_items</code> to one row per order in a subquery, then join that to <code dir=\"ltr\">orders</code>. Now both sides have one row per order, and nothing repeats:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"sql\" data-run=\"sql\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">SQL</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"sql\" data-index=\"1\" aria-label=\"Run this example\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>Run</span></button><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">channel</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  COUNT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> orders,</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  ROUND</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">SUM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">shipping_fee</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">), </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> shipping,</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  ROUND</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">SUM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">i</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">revenue</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">), </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> item_revenue</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> orders </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> o</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">JOIN</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  SELECT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_id, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">SUM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(quantity </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> unit_price </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">1</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> -</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> discount)) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> revenue</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_items</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  GROUP BY</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_id</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> i </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">ON</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> i</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_id</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">status</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> &#x3C;></span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'cancelled'</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  AND</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_date</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> >=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '2025-01-01'</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  AND</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_date</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> &#x3C;</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">  '2026-01-01'</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">GROUP BY</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">channel</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span></code></pre><div class=\"run-output\" data-run-output=\"1\" hidden></div></div>\n<p>A subquery in <code dir=\"ltr\">FROM</code> (a <em>derived table</em>) behaves like a temporary table with the grain you chose, one row per <code dir=\"ltr\">order_id</code> here. Shipping now matches a query on <code dir=\"ltr\">orders</code> alone, item revenue is unchanged, and <code dir=\"ltr\">COUNT(*)</code> counts orders, because the rows are orders again. The next section shows a tidier way to write the same thing with <code dir=\"ltr\">WITH</code>.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>SUM(DISTINCT) as a fan-out fix</span></p>\n<p><code dir=\"ltr\">COUNT(DISTINCT o.order_id)</code> is a legitimate way to count orders after a fan-out, because order ids are unique. <code dir=\"ltr\">SUM(DISTINCT o.shipping_fee)</code> is not: it adds each distinct <em>amount</em> once. Hundreds of orders share the same 4.99 fee, so on Cartwheel’s data it returns 22.97 per channel, the sum of a few fee levels. DISTINCT deduplicates values, never rows.</p>\n</aside>\n<h2 id=\"sec-3\" tabindex=\"-1\">Two fans at once</h2>\n<p>Fan-out compounds. Julia asks how much revenue came from orders that needed a support ticket. Join items and tickets to the same orders, and each line is repeated once per ticket on its order; 110 orders have more than one ticket. The naive sum says 166,562. The right answer, which asks whether a ticket <em>exists</em> rather than joining every ticket, is 140,675:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"sql\" data-run=\"sql\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">SQL</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"sql\" data-index=\"2\" aria-label=\"Run this example\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>Run</span></button><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> ROUND</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">SUM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(quantity </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> unit_price </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">1</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> -</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> discount)), </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> revenue_with_tickets</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_items</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_id </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">IN</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_id </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> support_tickets);</span></span></code></pre><div class=\"run-output\" data-run-output=\"2\" hidden></div></div>\n<p><code dir=\"ltr\">IN (subquery)</code> filters without joining, so it can never multiply rows. This “does a match exist?” pattern, a semi-join, gets a full treatment in the next lesson along with its opposite.</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">Spotting a fan-out before it bites</h2>\n<p>You don’t have to wait for a wrong total. Before joining, ask of each join key: is it unique on this side? <code dir=\"ltr\">order_id</code> is unique in <code dir=\"ltr\">orders</code> (it’s the primary key) but not in <code dir=\"ltr\">order_items</code> or <code dir=\"ltr\">support_tickets</code>, so joining either one to <code dir=\"ltr\">orders</code> multiplies order rows. A quick probe tells you:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"sql\" data-run=\"sql\" data-index=\"3\"><div class=\"code-head\"><span class=\"code-title\">SQL</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"sql\" data-index=\"3\" aria-label=\"Run this example\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>Run</span></button><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  COUNT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)                 </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> rows_after_join,</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  COUNT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">DISTINCT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_id) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> distinct_orders</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_items;</span></span></code></pre><div class=\"run-output\" data-run-output=\"3\" hidden></div></div>\n<p>3,991 rows against 2,066 orders: a fan-out factor of nearly two. When those two numbers differ, any column you sum from the “one” side needs to be pre-aggregated or left out of the join. When they’re equal, the join is one-to-one and safe for sums from either side.</p>\n<h2 id=\"sec-5\" tabindex=\"-1\">The reconciliation habit</h2>\n<p>Every time you join, reconcile at least one total against a single-table version. Shipping by channel from the joined query should equal shipping by channel from <code dir=\"ltr\">orders</code> alone; item revenue should equal the sum over <code dir=\"ltr\">order_items</code> for the same orders. If they match, the join didn’t fan out. If they don’t, you’ve caught the bug before Karim’s board deck did.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Name the grain in a comment</span></p>\n<p>Start grouped queries with a comment like <code dir=\"ltr\">-- grain: one row per channel</code> and put <code dir=\"ltr\">-- grain: one row per order</code> above every derived table. It takes five seconds, and it makes fan-out visible to you and to anyone reviewing the query.</p>\n</aside>\n<p>Next you’ll join tables to themselves and look for rows that have <em>no</em> match at all.</p>\n","toc":[{"id":"sec-1","text":"Where the extra money comes from","level":2},{"id":"sec-2","text":"The fix: aggregate first, then join","level":2},{"id":"sec-3","text":"Two fans at once","level":2},{"id":"sec-4","text":"Spotting a fan-out before it bites","level":2},{"id":"sec-5","text":"The reconciliation habit","level":2}],"takeaways":["Joining a one-to-many relationship gives the result the grain of the many side and repeats every column from the one side.","Summing a column from the repeated side, like an order’s shipping fee after joining to items, counts it once per matching row.","Aggregate the many side to the grain of the one side in a subquery first, then join, so every number is counted exactly once.","COUNT(DISTINCT key) can rescue a count, but SUM(DISTINCT value) is never a fix because it also merges different rows that share a value.","Reconcile every joined total against the same total computed from its own table alone."],"further":[{"title":"SQLite SELECT, subqueries in the FROM clause","url":"https://www.sqlite.org/lang_select.html#fromclause"},{"title":"PostgreSQL table expressions and subqueries in FROM","url":"https://www.postgresql.org/docs/current/queries-table-expressions.html"}],"quiz":[{"q":"<p>Order 1001 has a shipping fee of 7.99 and four item lines. After joining <code dir=\"ltr\">orders</code> to <code dir=\"ltr\">order_items</code>, what does <code dir=\"ltr\">SUM(o.shipping_fee)</code> contribute for this order?</p>","options":[{"text":"7.99","why":"That’s the true fee, but the join produced four rows for this order, each carrying 7.99."},{"text":"31.96","why":"Correct. The order’s columns are repeated on each of its four item rows, so the fee is summed four times."},{"text":"1.99, the fee divided across items","why":"Joins never split values; they copy the one-side columns onto every matching row."}],"answer":1},{"q":"<p>A colleague fixes an inflated shipping total by writing <code dir=\"ltr\">SUM(DISTINCT o.shipping_fee)</code>. What’s wrong with that?</p>","options":[{"text":"Nothing; DISTINCT removes the duplicates the join created.","why":"DISTINCT removes duplicate values, not duplicate orders. 372 different orders share a 4.99 fee, and they collapse into one."},{"text":"SUM(DISTINCT) is not valid SQL.","why":"It’s valid in SQLite and PostgreSQL, which is what makes it a tempting, silent bug."},{"text":"It adds up each distinct fee amount once, so the total is close to the sum of a handful of fee levels.","why":"Correct. On Cartwheel’s data it returns 22.97 per channel, the sum of the distinct fee levels, instead of the real 678 to 3,413."},{"text":"It’s correct but slower than the subquery approach.","why":"It isn’t correct. Speed is beside the point when the number is wrong."}],"answer":2},{"q":"<p>You need item revenue and ticket count per order. Both <code dir=\"ltr\">order_items</code> and <code dir=\"ltr\">support_tickets</code> have many rows per order. What’s the safest approach?</p>","options":[{"text":"Join orders to both tables in one FROM clause and use SUM and COUNT.","why":"Two independent one-to-many joins multiply each other. Revenue gets counted once per ticket and tickets once per item."},{"text":"Use COUNT(DISTINCT t.ticket_id) and SUM(DISTINCT revenue).","why":"The count would survive, but SUM(DISTINCT) merges different lines that happen to have equal revenue."},{"text":"Use a LEFT JOIN instead of an INNER JOIN for both tables.","why":"The join type controls which rows survive, not how many times they repeat; fan-out happens with both."},{"text":"Aggregate items per order and tickets per order in two subqueries, then join both to orders.","why":"Correct. Each subquery returns one row per order, so the final joins are one-to-one and nothing repeats."}],"answer":3}],"exercise":{"kind":"sql","starter":"-- grain: one row per country (2025, non-cancelled orders)\nSELECT\n  c.country,\n  COUNT(*) AS orders,\n  ROUND(SUM(o.shipping_fee), 2) AS shipping_total,\n  ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount)), 2) AS item_revenue\nFROM orders AS o\nJOIN customers AS c ON c.customer_id = o.customer_id\nJOIN order_items AS oi ON oi.order_id = o.order_id\nWHERE o.status <> 'cancelled'\n  AND o.order_date >= '2025-01-01'\n  AND o.order_date < '2026-01-01'\nGROUP BY c.country;","solution":"SELECT\n  c.country,\n  COUNT(*) AS orders,\n  ROUND(SUM(o.shipping_fee), 2) AS shipping_total,\n  ROUND(SUM(i.revenue), 2) AS item_revenue\nFROM orders AS o\nJOIN customers AS c ON c.customer_id = o.customer_id\nJOIN (\n  -- grain: one row per order\n  SELECT order_id, SUM(quantity * unit_price * (1 - discount)) AS revenue\n  FROM order_items\n  GROUP BY order_id\n) AS i ON i.order_id = o.order_id\nWHERE o.status <> 'cancelled'\n  AND o.order_date >= '2025-01-01'\n  AND o.order_date < '2026-01-01'\nGROUP BY c.country;","orderMatters":false,"title":"Shipping by country, counted once","prompt":"<p>Karim wants the same breakdown <strong>by customer country</strong> for 2025 (non-cancelled orders): <code dir=\"ltr\">country</code>, <code dir=\"ltr\">orders</code> (number of orders), <code dir=\"ltr\">shipping_total</code> (sum of <code dir=\"ltr\">shipping_fee</code>, rounded to 2 decimals) and <code dir=\"ltr\">item_revenue</code> (rounded to 2 decimals). Row order doesn’t matter.</p>\n<p>The starter’s item revenue is right, but its order counts and shipping totals are inflated. Fix the grain.</p>\n","hints":["Which table is the many side? After joining it, what does one row represent, and how often does each order’s <code dir=\"ltr\">shipping_fee</code> appear?","Replace the join to <code dir=\"ltr\">order_items</code> with a join to a subquery that returns one row per <code dir=\"ltr\">order_id</code> with its revenue.","Inside the subquery: <code dir=\"ltr\">SELECT order_id, SUM(quantity * unit_price * (1 - discount)) AS revenue FROM order_items GROUP BY order_id</code>. Then sum <code dir=\"ltr\">i.revenue</code> outside."],"explanation":"<p>Joining <code dir=\"ltr\">order_items</code> turns each order into one row per line, so <code dir=\"ltr\">COUNT(*)</code> counted lines and <code dir=\"ltr\">SUM(o.shipping_fee)</code> counted each fee once per line. Collapsing items to one row per order first makes every join one-to-one at the order grain, so orders and fees are counted once while item revenue stays the same. The UK leads with 241 orders and about 58,000 in item revenue. <code dir=\"ltr\">COUNT(DISTINCT o.order_id)</code> would also fix the count, but no DISTINCT trick fixes the shipping sum.</p>\n","checkLabels":{}},"words":704,"readMinutes":3}