Skip to main content
ANVISoftware Solutions
Lesson 10 of 22Intermediate18 min

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.
Aggregating the whole table, then per group
SQL
-- 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:

 WHEREHAVING
RunsBefore groupingAfter grouping
Operates onIndividual rowsGroups, and their aggregate values
Can use aggregatesNo — no groups exist yet, so there is nothing to aggregateYes, that is its purpose
Typical useOnly 2026 ordersOnly customers with more than five orders
Effect on performanceReduces rows before the expensive grouping workDiscards results after the work is already done
Both in one query
SQL
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

Monthly sales, with the count of distinct customers
SQL
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.

SQL
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.

SQL
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.

Why is WHERE COUNT(*) > 5 rejected?
On a LEFT JOIN from customers to orders, why does COUNT(o.order_id) show 0 for a customer with no orders while COUNT(*) shows 1?

Saved in this browser only.