EduFlow
SQL for Analysts
0%

Section 2 · Aggregation and Joins

GROUP BY, HAVING and the Grain of a Result

17 min3 min readSQL exercise

Lesson at a glance

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.

  1. 1One row per what?
  2. 2WHERE versus HAVING
  3. 3Counting a subset inside each group
  4. 4Checking the grain

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

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 row per priority, each summarising dozens of tickets. That’s what GROUP BY does, and the idea underneath it, the grain of a result, is the single most useful concept in analytical SQL.

One row per what?

Every table and every result has a grain: the thing one row represents. support_tickets has one row per ticket. Mark wants one row per priority. Write that down first, then make the query match it:

SQL
SELECT
  priority,
  COUNT(*)                AS tickets,
  COUNT(closed_at)        AS closed,
  ROUND(AVG((julianday(closed_at) - julianday(opened_at)) * 24), 1) AS avg_hours,
  ROUND(MAX((julianday(closed_at) - julianday(opened_at)) * 24), 1) AS max_hours
FROM support_tickets
GROUP BY priority
ORDER BY avg_hours;

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 COUNT(closed_at) and AVG quietly skip open tickets, exactly as the previous lessons promised. The max_hours column matters too: an average can look healthy while one customer waited a week.

Logical evaluation order: FROM, then WHERE filters rows, GROUP BY forms groups, HAVING filters groups, SELECT computes output columns, ORDER BY sorts, LIMIT cuts. FROM WHERE GROUP BY HAVING SELECT ORDER BY LIMIT filters rows makes groups filters groups computes
The logical order SQL evaluates a query in

That diagram explains most GROUP BY errors. WHERE runs before groups exist, so it can’t test COUNT(*); that’s HAVING’s job. ORDER BY runs after SELECT, so it can always use a column alias. SQLite and PostgreSQL also accept an alias in GROUP BY as a convenience, which is why GROUP BY month works below, but the logical order is still the one shown.

WHERE versus HAVING

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 HAVING. “No discount” is a property of each order line, so it’s a row condition and goes in WHERE:

SQL
SELECT
  product_id,
  SUM(quantity) AS units,
  COUNT(DISTINCT order_id) AS orders
FROM order_items
WHERE discount = 0
GROUP BY product_id
HAVING SUM(quantity) >= 100
ORDER BY units DESC
LIMIT 5;

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. COUNT(DISTINCT order_id) 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.

Counting a subset inside each group

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 FILTER clause restricts one aggregate to some rows:

SQL
SELECT
  strftime('%Y-%m', order_date) AS month,
  COUNT(*) AS orders,
  COUNT(*) FILTER (WHERE channel = 'mobile_app') AS app_orders,
  ROUND(100.0 * COUNT(*) FILTER (WHERE channel = 'mobile_app') / COUNT(*), 1) AS app_pct
FROM orders
WHERE order_date >= '2025-07-01'
  AND order_date <  '2026-01-01'
GROUP BY month
ORDER BY month;

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. 100.0 rather than 100 forces decimal division; SQLite divides integers as integers, so 47 / 129 is 0. And FILTER is standard SQL, supported by SQLite and PostgreSQL. In engines without it, write SUM(CASE WHEN channel = 'mobile_app' THEN 1 ELSE 0 END), which is the same count spelled the long way.

Checking the grain

Once a grouped query runs, verify it before reporting:

  1. Row count: does it match the number of groups you expect (4 priorities, 6 months)?
  2. Uniqueness: is the GROUP BY key unique in the output? It is by construction, unless a bare column is hiding something.
  3. Totals: does the sum of a column across groups equal the ungrouped total? For tickets, the four counts add up to 873.

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.

Key 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 reading

Check your understanding

4 questions

Question 1 of 4

You need countries whose 2025 order count is above 50. Where does each condition go?

Question 2 of 4

What is the grain of this result?

SQL
SELECT customer_id, strftime('%Y-%m', order_date) AS month, COUNT(*)
FROM orders
GROUP BY customer_id, month;
Question 3 of 4

In SQLite, SELECT channel, status, COUNT(*) FROM orders GROUP BY channel runs without error. What does the status column show?

Question 4 of 4

Which expression gives the share of each month’s orders that were cancelled, as a percentage?

Practice

SQL exercise

Karim's best sellers

Karim wants every product that has sold at least 130 units across all order lines (ignore order status for now).

Return one row per product with product_id, units (total quantity) and revenue (sum of quantity * unit_price * (1 - discount), rounded to 2 decimals). Row order doesn’t matter.

The starter returns a single row. Make it return the right grain and apply the threshold.

Tables
customers
(customer_id, first_name, last_name, email, country, city, signup_date, segment, referred_by)
orders
(order_id, customer_id, order_date, status, channel, coupon_code, shipping_fee)
order_items
(order_item_id, order_id, product_id, quantity, unit_price, discount)
products
(product_id, name, category_id, unit_price, unit_cost, launched_on, is_active)
categories
(category_id, name, parent_id)
returns
(return_id, order_item_id, return_date, reason, refund_amount)
employees
(employee_id, first_name, last_name, title, department, manager_id, hire_date, salary)
support_tickets
(ticket_id, customer_id, order_id, opened_at, closed_at, priority, channel, satisfaction, agent_id)
web_sessions
(session_id, customer_id, started_at, source, device, pages_viewed, added_to_cart, purchased)
-- Best-selling products: at least 130 units
SELECT
  product_id,
  SUM(quantity) AS units,
  ROUND(SUM(quantity * unit_price * (1 - discount)), 2) AS revenue
FROM order_items
ORDER BY units DESC;Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.