Grouping and Aggregation
By the end of this lesson
Summarise rows into totals and counts, and filter groups with HAVING.
Nobody wants two million order rows. They want revenue by month, or the five best-selling products, or how many customers ordered last quarter.
Aggregation turns many rows into one number. GROUP BY decides which rows get bundled together before that number is worked out.
The five aggregate functions that cover almost everything:
- COUNT(*)
- How many rows are in the group. Counts every row, including ones full of NULLs.
- COUNT(column)
- How many rows have a non-null value in that column. Different from COUNT(*) whenever the column is nullable, which is a difference worth being deliberate about.
- COUNT(DISTINCT column)
- How many different non-null values appear. This is how you count customers rather than orders.
- SUM(column) and AVG(column)
- Total and mean of the non-null values. AVG ignores NULLs rather than treating them as zero, so the divisor is the count of non-null values, not the row count.
- MIN(column) and MAX(column)
- Smallest and largest value. Work on dates and text as well as numbers, so MAX(order_date) gives the most recent order.
-- No GROUP BY: the whole table is one group, so you get one row
SELECT
COUNT(*) AS order_count,
SUM(shipping_fee) AS total_shipping,
MIN(order_date) AS first_order,
MAX(order_date) AS latest_order
FROM orders;
-- With GROUP BY: one row per distinct status
SELECT
status,
COUNT(*) AS order_count,
SUM(shipping_fee) AS total_shipping
FROM orders
GROUP BY status
ORDER BY order_count DESC;- An aggregate with no GROUP BY collapses everything to a single row. That is a group too — the group is "all rows".
- GROUP BY status creates one bucket per distinct status value, then computes the aggregates inside each bucket.
- Every column in the SELECT list must either be in the GROUP BY or be wrapped in an aggregate. There is a reason for that rule, and it is in the next section.
Why you cannot select a column you did not group by
Group orders by status and one row in the result represents perhaps 40,000 orders. Now ask for order_date in that row. Which of the 40,000 dates should the database return?
There is no defensible answer, so the database refuses. The error usually reads "column is invalid in the select list because it is not contained in either an aggregate function or the GROUP BY clause", which is long but precise.
MySQL historically allowed this and returned an arbitrary value from the group. Recent versions reject it by default. If you meet a query relying on the old behaviour, treat the values it returns as unreliable rather than assuming they were chosen sensibly.
WHERE filters rows, HAVING filters groups
This is the distinction the lesson turns on, and the execution order from the SELECT lesson explains it completely.
WHERE runs before GROUP BY. At that point there are no groups yet — only individual rows — so WHERE decides which rows are allowed into the grouping.
HAVING runs after GROUP BY. By then the aggregates have been calculated, so HAVING can test them. "Customers with more than five orders" is a statement about a group, so it belongs in HAVING.
Same keyword shape, entirely different job:
| WHERE | HAVING | |
|---|---|---|
| Runs | Before grouping | After grouping |
| Operates on | Individual rows | Groups, and their aggregate values |
| Can use aggregates | No — no groups exist yet, so there is nothing to aggregate | Yes, that is its purpose |
| Typical use | Only 2026 orders | Only customers with more than five orders |
| Effect on performance | Reduces rows before the expensive grouping work | Discards results after the work is already done |
SELECT
c.company_name,
COUNT(DISTINCT o.order_id) AS order_count,
SUM(oi.quantity * oi.unit_price) AS goods_value
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.customer_id
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.order_date >= '2026-01-01' -- filter rows first
AND o.status <> 'cancelled'
GROUP BY c.company_name
HAVING SUM(oi.quantity * oi.unit_price) > 10000 -- then filter groups
ORDER BY goods_value DESC;- WHERE removes 2025 orders and cancelled orders before any grouping happens, so they never reach the totals.
- HAVING then drops customers whose total came out below 10,000. That test needs the total, which only exists after grouping.
- COUNT(DISTINCT o.order_id) rather than COUNT(*) is the joins lesson showing up again: the join to order_items repeated each order once per line, so COUNT(*) would count lines and call them orders.
- SQL Server requires the full expression in HAVING rather than the alias, because HAVING is resolved before SELECT. PostgreSQL and MySQL permit the alias.
A realistic example: revenue by month
SELECT
YEAR(o.order_date) AS order_year,
MONTH(o.order_date) AS order_month,
COUNT(DISTINCT o.order_id) AS orders,
COUNT(DISTINCT o.customer_id) AS customers,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.status <> 'cancelled'
GROUP BY YEAR(o.order_date), MONTH(o.order_date)
ORDER BY order_year, order_month;- You can group by an expression, not only a column. The same expression has to appear in both the GROUP BY and the SELECT list.
- Two DISTINCT counts from one query: how many orders, and how many different customers placed them. Those numbers answer different questions and are often confused in reports.
- One honest limitation: months with no orders at all do not appear, because there are no rows to group. Showing a zero for a quiet month needs a list of months to join against — a calendar table, or a generated series.
Summary
- Aggregate functions reduce many rows to one value; GROUP BY decides which rows form each bundle
- Every non-aggregated SELECT column must appear in GROUP BY, because otherwise the value would be arbitrary
- WHERE filters rows before grouping; HAVING filters groups after, and only HAVING can test an aggregate
- After a join, COUNT(*) counts joined rows — use COUNT(DISTINCT key) to count the thing you meant
- AVG and SUM ignore NULLs rather than treating them as zero
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Produce a list of product categories with the number of products in each, the average price, and the most expensive price. Include only categories with more than three products, and sort by average price descending.
Show solution
One group per category, three aggregates, and a HAVING clause because "more than three products" is a fact about the group rather than about any single row.
discontinued = 0 in WHERE is a row-level condition, so it belongs there. Putting it in HAVING would not work, and it would also make the database aggregate rows it is going to discard.
Note that COUNT(*) is correct here because there is no join — one row is one product. As soon as you join to a child table, that assumption stops holding.
SELECT
p.category,
COUNT(*) AS product_count,
AVG(p.unit_price) AS average_price,
MAX(p.unit_price) AS highest_price
FROM products AS p
WHERE p.discontinued = 0
GROUP BY p.category
HAVING COUNT(*) > 3
ORDER BY average_price DESC;Think about it
Think about it
A report counts "customers who ordered in 2026" using COUNT(*) on a query that joins customers to orders. The number is far higher than the total number of customers. What is being counted, and what should be?
Show solution
COUNT(*) is counting joined rows, which is one per order. A customer with 40 orders contributes 40 to the count, so the figure is really "orders placed by customers in 2026".
COUNT(DISTINCT c.customer_id) counts each customer once regardless of how many orders they placed.
The general habit: after any join, ask what one row of the result represents. Every COUNT you write is counting that thing, whatever your column alias claims.
SELECT COUNT(DISTINCT c.customer_id) AS customers_who_ordered
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.customer_id
WHERE o.order_date >= '2026-01-01'
AND o.order_date < '2027-01-01'
AND o.status <> 'cancelled';Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.