{"id":"l-sql-2-1","courseId":"sql-for-analysts","lang":"en","kind":"lesson","summary":"Collapse rows into one per group with GROUP BY, filter groups with HAVING, count conditionally with FILTER, and state the grain of every result before you trust it.","html":"<p>Mark from Operations is back with the question you couldn’t answer last lesson: “What’s our average resolution time, by priority?” You know how to compute one ticket’s hours. Now you need one <em>row per priority</em>, each summarising dozens of tickets. That’s what <code dir=\"ltr\">GROUP BY</code> does, and the idea underneath it, the grain of a result, is the single most useful concept in analytical SQL.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">One row per what?</h2>\n<p>Every table and every result has a <strong>grain</strong>: the thing one row represents. <code dir=\"ltr\">support_tickets</code> has one row per ticket. Mark wants one row per priority. Write that down first, then make the query match it:</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:#1F2328;--shiki-dark:#E6EDF3\">  priority,</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\"> tickets,</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  COUNT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(closed_at)        </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> closed,</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\">AVG</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((julianday(closed_at) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">-</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> julianday(opened_at)) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 24</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">), </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">1</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\"> avg_hours,</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\">MAX</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((julianday(closed_at) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">-</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> julianday(opened_at)) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 24</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">), </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">1</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\"> max_hours</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> support_tickets</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">GROUP BY</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> priority</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">ORDER BY</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> avg_hours;</span></span></code></pre><div class=\"run-output\" data-run-output=\"0\" hidden></div></div>\n<p>Four rows, one per priority, which matches the grain you stated. Urgent tickets close in 6.5 hours on average, high in about a day, and low and normal in over three days. Notice how <code dir=\"ltr\">COUNT(closed_at)</code> and <code dir=\"ltr\">AVG</code> quietly skip open tickets, exactly as the previous lessons promised. The <code dir=\"ltr\">max_hours</code> column matters too: an average can look healthy while one customer waited a week.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 720 150\" role=\"img\" aria-labelledby=\"l-sql-2-1-t1\"><defs><marker id=\"arrow-l-sql-2-1-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-1-t1\">Logical evaluation order: FROM, then WHERE filters rows, GROUP BY forms groups, HAVING filters groups, SELECT computes output columns, ORDER BY sorts, LIMIT cuts.</title>\n  <rect class=\"d-box\" x=\"10\" y=\"40\" width=\"86\" height=\"44\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"53\" y=\"67\" text-anchor=\"middle\">FROM</text>\n  <rect class=\"d-box-accent\" x=\"111\" y=\"40\" width=\"86\" height=\"44\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"154\" y=\"67\" text-anchor=\"middle\">WHERE</text>\n  <rect class=\"d-box-primary\" x=\"212\" y=\"40\" width=\"100\" height=\"44\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"262\" y=\"67\" text-anchor=\"middle\">GROUP BY</text>\n  <rect class=\"d-box-accent\" x=\"327\" y=\"40\" width=\"86\" height=\"44\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"370\" y=\"67\" text-anchor=\"middle\">HAVING</text>\n  <rect class=\"d-box\" x=\"428\" y=\"40\" width=\"86\" height=\"44\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"471\" y=\"67\" text-anchor=\"middle\">SELECT</text>\n  <rect class=\"d-box\" x=\"529\" y=\"40\" width=\"100\" height=\"44\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"579\" y=\"67\" text-anchor=\"middle\">ORDER BY</text>\n  <rect class=\"d-box\" x=\"644\" y=\"40\" width=\"66\" height=\"44\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"677\" y=\"67\" text-anchor=\"middle\">LIMIT</text>\n  <text class=\"d-label-muted\" x=\"154\" y=\"110\" text-anchor=\"middle\">filters rows</text>\n  <text class=\"d-label-muted\" x=\"262\" y=\"110\" text-anchor=\"middle\">makes groups</text>\n  <text class=\"d-label-muted\" x=\"370\" y=\"110\" text-anchor=\"middle\">filters groups</text>\n  <text class=\"d-label-muted\" x=\"471\" y=\"110\" text-anchor=\"middle\">computes</text>\n  <path class=\"d-arrow\" d=\"M30 130 L690 130\" marker-end=\"url(#arrow-l-sql-2-1-1)\"/>\n</svg><figcaption>The logical order SQL evaluates a query in</figcaption></figure>\n<p>That diagram explains most GROUP BY errors. <code dir=\"ltr\">WHERE</code> runs before groups exist, so it can’t test <code dir=\"ltr\">COUNT(*)</code>; that’s <code dir=\"ltr\">HAVING</code>’s job. <code dir=\"ltr\">ORDER BY</code> runs after SELECT, so it can always use a column alias. SQLite and PostgreSQL also accept an alias in <code dir=\"ltr\">GROUP BY</code> as a convenience, which is why <code dir=\"ltr\">GROUP BY month</code> works below, but the logical order is still the one shown.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">WHERE versus HAVING</h2>\n<p>Karim in Finance wants to know which products have sold at least 100 units at full price, with no discount. Units per product is an aggregate, so the threshold goes in <code dir=\"ltr\">HAVING</code>. “No discount” is a property of each order line, so it’s a row condition and goes in <code dir=\"ltr\">WHERE</code>:</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:#1F2328;--shiki-dark:#E6EDF3\">  product_id,</span></span>\n<span class=\"line\"><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\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> units,</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\"> 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>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> discount </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">GROUP BY</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> product_id</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">HAVING</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:#0550AE;--shiki-dark:#79C0FF\"> 100</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">ORDER BY</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> units </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">DESC</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">LIMIT</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 5</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>Read it in the diagram’s order: drop discounted lines, group what’s left by product, keep products with 100+ units, then compute, sort and cut. <code dir=\"ltr\">COUNT(DISTINCT order_id)</code> counts orders rather than lines, because in Cartwheel’s data each product appears at most once per order, so lines and orders differ only when you count across products.</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>Bare columns in SQLite</span></p>\n<p>Every column in SELECT should be in <code dir=\"ltr\">GROUP BY</code> or inside an aggregate. PostgreSQL enforces that rule; SQLite doesn’t. <code dir=\"ltr\">SELECT channel, status, COUNT(*) FROM orders GROUP BY channel</code> runs and shows “delivered” for every channel, because SQLite filled <code dir=\"ltr\">status</code> from one arbitrary row of each group. Worse, forgetting <code dir=\"ltr\">GROUP BY</code> entirely collapses the table to a <strong>single row</strong> with an arbitrary product id next to the grand total. If a result has fewer rows than you expected, check the GROUP BY first.</p>\n</aside>\n<h2 id=\"sec-3\" tabindex=\"-1\">Counting a subset inside each group</h2>\n<p>Huda in Marketing asks what share of each month’s orders came through the mobile app. You need two counts per month, all orders and app orders. A <code dir=\"ltr\">FILTER</code> clause restricts one aggregate to some rows:</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>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  strftime(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'%Y-%m'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, order_date) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> month</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\">  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\">FILTER</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> channel </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'mobile_app'</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\"> app_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\">100</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> *</span><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\">FILTER</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> channel </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'mobile_app'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">/</span><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:#0550AE;--shiki-dark:#79C0FF\">1</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\"> app_pct</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>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_date </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">>=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '2025-07-01'</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  AND</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> 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:#CF222E;--shiki-dark:#FF7B72\"> month</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">ORDER BY</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> month</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span></code></pre><div class=\"run-output\" data-run-output=\"2\" hidden></div></div>\n<p>The app share moves between roughly 28% and 40%, and November’s volume is more than three times July’s, thanks to Black Friday. Two details matter. <code dir=\"ltr\">100.0</code> rather than <code dir=\"ltr\">100</code> forces decimal division; SQLite divides integers as integers, so <code dir=\"ltr\">47 / 129</code> is 0. And <code dir=\"ltr\">FILTER</code> is standard SQL, supported by SQLite and PostgreSQL. In engines without it, write <code dir=\"ltr\">SUM(CASE WHEN channel = 'mobile_app' THEN 1 ELSE 0 END)</code>, which is the same count spelled the long way.</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">Checking the grain</h2>\n<p>Once a grouped query runs, verify it before reporting:</p>\n<ol>\n<li><strong>Row count</strong>: does it match the number of groups you expect (4 priorities, 6 months)?</li>\n<li><strong>Uniqueness</strong>: is the GROUP BY key unique in the output? It is by construction, unless a bare column is hiding something.</li>\n<li><strong>Totals</strong>: does the sum of a column across groups equal the ungrouped total? For tickets, the four counts add up to 873.</li>\n</ol>\n<p>None of these checks takes more than a minute, and together they catch the majority of grouping mistakes before a stakeholder does. Make them part of the loop from the first lesson, between “query” and “answer”. That third check becomes vital in the next lessons, when you start joining tables. Joins can change the grain without telling you, and summing over the wrong grain is how double counting starts.</p>\n","toc":[{"id":"sec-1","text":"One row per what?","level":2},{"id":"sec-2","text":"WHERE versus HAVING","level":2},{"id":"sec-3","text":"Counting a subset inside each group","level":2},{"id":"sec-4","text":"Checking the grain","level":2}],"takeaways":["The grain of a result is what one row represents; say it out loud before writing GROUP BY and check it after.","Every column in the SELECT list should be either in GROUP BY or inside an aggregate; SQLite allows bare columns but fills them from an arbitrary row.","WHERE filters rows before grouping, HAVING filters groups after aggregation, so conditions on SUM or COUNT belong in HAVING.","COUNT(*) FILTER (WHERE …) or SUM(CASE WHEN … THEN 1 ELSE 0 END) counts a subset inside each group without a second query."],"further":[{"title":"SQLite aggregate functions","url":"https://www.sqlite.org/lang_aggfunc.html"},{"title":"SQLite SELECT processing, including bare columns","url":"https://www.sqlite.org/lang_select.html#resultset"},{"title":"PostgreSQL aggregate expressions and FILTER","url":"https://www.postgresql.org/docs/current/sql-expressions.html#SYNTAX-AGGREGATES"}],"quiz":[{"q":"<p>You need countries whose 2025 order count is above 50. Where does each condition go?</p>","options":[{"text":"Both the year test and <code dir=\"ltr\">COUNT(*) &gt; 50</code> go in WHERE.","why":"WHERE runs before grouping, when no count exists yet, so an aggregate there is an error."},{"text":"Both go in HAVING.","why":"PostgreSQL rejects a non-grouped <code dir=\"ltr\">order_date</code> in HAVING, and SQLite would test the date of one arbitrary row per group, giving silently wrong counts. The year test is a row condition and belongs in WHERE."},{"text":"The year test goes in HAVING; <code dir=\"ltr\">COUNT(*) &gt; 50</code> goes in WHERE.","why":"That’s reversed. WHERE can’t see aggregates at all."},{"text":"The year test goes in WHERE; <code dir=\"ltr\">COUNT(*) &gt; 50</code> goes in HAVING.","why":"Correct. Filter rows first (only 2025 orders), then filter the groups by their aggregate."}],"answer":3},{"q":"<p>What is the grain of this result?</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"sql\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">SQL</span><span class=\"code-actions\"><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:#1F2328;--shiki-dark:#E6EDF3\"> customer_id, strftime(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'%Y-%m'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, order_date) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> month</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><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>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> orders</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">GROUP BY</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> customer_id, </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">month</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span></code></pre></div>\n","options":[{"text":"One row per customer.","why":"The month is also a grouping key, so a customer who ordered in three months gets three rows."},{"text":"One row per order.","why":"Grouping collapses orders; several orders by the same customer in one month become one row."},{"text":"One row per month.","why":"Each month has a row for every customer who ordered in it, not a single row."},{"text":"One row per customer per month in which they ordered.","why":"Correct. The grain is the combination of the GROUP BY keys, and months with no orders don’t appear at all."}],"answer":3},{"q":"<p>In SQLite, <code dir=\"ltr\">SELECT channel, status, COUNT(*) FROM orders GROUP BY channel</code> runs without error. What does the <code dir=\"ltr\">status</code> column show?</p>","options":[{"text":"The status of some row in each channel group, which tells you nothing about the group.","why":"Correct. SQLite allows this “bare column” and fills it from an arbitrary row. PostgreSQL rejects the query."},{"text":"The most common status in each channel.","why":"There’s no mode calculation; SQLite picks a value from one row of the group."},{"text":"A comma-separated list of every status.","why":"That’s what <code dir=\"ltr\">GROUP_CONCAT(status)</code> (or <code dir=\"ltr\">string_agg</code>) does; a bare column is a single value."}],"answer":0},{"q":"<p>Which expression gives the share of each month’s orders that were cancelled, as a percentage?</p>","options":[{"text":"<code dir=\"ltr\">COUNT(status = 'cancelled') * 100.0 / COUNT(*)</code>","why":"COUNT counts non-NULL values, and <code dir=\"ltr\">status = 'cancelled'</code> is 0 or 1, never NULL, so this counts every row and returns 100."},{"text":"<code dir=\"ltr\">100.0 * COUNT(*) FILTER (WHERE status = 'cancelled') / COUNT(*)</code>","why":"Correct. FILTER restricts that one aggregate to cancelled rows; 100.0 avoids integer division."},{"text":"<code dir=\"ltr\">COUNT(*) / COUNT(*) FILTER (WHERE status = 'cancelled')</code>","why":"That’s the inverse ratio, and with integer division in SQLite it would truncate as well."}],"answer":1}],"exercise":{"kind":"sql","starter":"-- Best-selling products: at least 130 units\nSELECT\n  product_id,\n  SUM(quantity) AS units,\n  ROUND(SUM(quantity * unit_price * (1 - discount)), 2) AS revenue\nFROM order_items\nORDER BY units DESC;","solution":"SELECT\n  product_id,\n  SUM(quantity) AS units,\n  ROUND(SUM(quantity * unit_price * (1 - discount)), 2) AS revenue\nFROM order_items\nGROUP BY product_id\nHAVING SUM(quantity) >= 130\nORDER BY units DESC, product_id;","orderMatters":false,"title":"Karim's best sellers","prompt":"<p>Karim wants every product that has sold <strong>at least 130 units</strong> across all order lines (ignore order status for now).</p>\n<p>Return one row per product with <code dir=\"ltr\">product_id</code>, <code dir=\"ltr\">units</code> (total quantity) and <code dir=\"ltr\">revenue</code> (sum of <code dir=\"ltr\">quantity * unit_price * (1 - discount)</code>, rounded to 2 decimals). Row order doesn’t matter.</p>\n<p>The starter returns a single row. Make it return the right grain and apply the threshold.</p>\n","hints":["State the grain: one row per product. Which clause makes that happen?","Add <code dir=\"ltr\">GROUP BY product_id</code>. Now every product appears; you need to keep only some groups.","A condition on <code dir=\"ltr\">SUM(quantity)</code> can’t go in WHERE. Use <code dir=\"ltr\">HAVING SUM(quantity) &gt;= 130</code>."],"explanation":"<p>Without GROUP BY, SQLite aggregates the whole table into one row and fills the bare <code dir=\"ltr\">product_id</code> from an arbitrary line, so the starter shows one product next to the company-wide total. <code dir=\"ltr\">GROUP BY product_id</code> sets the grain to one row per product, and <code dir=\"ltr\">HAVING</code> filters those groups after the sums exist. Eleven products clear the bar. The Standing Desk 140cm (product 21) has only 132 units but more than 60,000 in revenue, a reminder that units and revenue rank products very differently.</p>\n","checkLabels":{}},"words":697,"readMinutes":3}