Summarizing rows: GROUP BY

Aggregation summarizes many rows into counts, totals, and averages. Instead of individual orders, you can ask for results by status, customer, or category.

One number per group

GROUP BY collects rows that share a value, and aggregate functions reduce each group to a single value.

1

SELECT status, COUNT(*) AS orders
FROM orders
GROUP BY status
ORDER BY orders DESC;
Run in Planche

Result — first 5 of 7 rows

statusorders
delivered1351
paid695
shipped641
pending349
refunded327

Opens Planche in a separate browser tab, with the sample database loaded and this query ready. Every example on the page reuses that tab, and nothing runs until you press F9.

Seven statuses produce seven rows. Every item in the SELECT list must be a grouping column or an aggregate. Otherwise the result is ambiguous: if you also select order_number, which of the 1,351 delivered orders should be shown?

COUNT(*) is not COUNT(column)

2

SELECT COUNT(*) AS customers,
       COUNT(phone) AS with_phone,
       COUNT(*) - COUNT(phone) AS without_phone
FROM customers;
Run in Planche

Result — 1 row

customerswith_phonewithout_phone
15001221279

COUNT(*) counts all rows. COUNT(phone) counts only rows where phone is not NULL. Their difference is the number of missing phone values, so no second query is needed.

Every other aggregate skips NULLs too, which matters most for AVG. The average of 10, 20 and NULL is 15, not 10 — the NULL is not treated as a zero, it is not counted at all. When you do want it treated as zero, say so with COALESCE(column, 0).

Several aggregates at once

3

SELECT cat.name AS category,
       COUNT(*) AS products,
       ROUND(MIN(p.price), 2) AS cheapest,
       ROUND(AVG(p.price), 2) AS average,
       ROUND(MAX(p.price), 2) AS dearest
FROM products p
JOIN categories cat ON cat.id = p.category_id
GROUP BY cat.id
ORDER BY average DESC
LIMIT 20;
Run in Planche

Result — first 5 of 20 rows

categoryproductscheapestaveragedearest
Wearables11417.31672.292433.73
Programming106.891585.672420.02
Tablets17314.761529.452412.93
Audio11593.81482.892334.28
Kitchen12670.541443.212293.8

The database calculates five values for each category in one pass over the table. Doing the same work in application code would mean transferring and processing all 400 product rows; the query returns only the 20 summary rows.

Grouping across a join

Aggregates and joins can be combined: join the tables first, then group the resulting rows.

4

SELECT cat.name AS category,
       COUNT(DISTINCT o.id) AS orders,
       SUM(oi.quantity) AS units,
       ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
JOIN categories cat ON cat.id = p.category_id
WHERE o.status IN ('paid', 'shipped', 'delivered')
GROUP BY cat.id
ORDER BY revenue DESC;
Run in Planche

Result — first 5 of 27 rows

categoryordersunitsrevenue
Watches395815933900.92
Board Games395801929534.55
Laptops366768900778.09
Science340709888542.78
Cycling405823858478.88

WHERE before, HAVING after

Both clauses filter results, but at different stages of the query.

5

SELECT cat.name AS category,
       ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue
FROM order_items oi
JOIN products p ON p.id = oi.product_id
JOIN categories cat ON cat.id = p.category_id
GROUP BY cat.id
HAVING SUM(oi.quantity * oi.unit_price) > 1000000
ORDER BY revenue DESC;
Run in Planche

Result — first 5 of 8 rows

categoryrevenue
Board Games1377454.99
Tablets1348572.94
Science1347046.09
Watches1325717.58
Cycling1288143.46

WHERE runs before grouping and decides which rows take part. HAVING runs after and decides which groups survive. So a condition on a raw column goes in WHERE, and a condition on an aggregate can only go in HAVING — the aggregate does not exist yet while WHERE is running.

The clause sequence from the SELECT lesson makes the distinction clear:

FROM  ->  WHERE  ->  GROUP BY  ->  HAVING  ->  SELECT  ->  ORDER BY  ->  LIMIT

Grouping by something you compute

You can group by an expression, not only a column. Cutting the first seven characters off a timestamp gives 2025-01 — a month.

6

SELECT substr(ordered_at, 1, 7) AS month,
       COUNT(*) AS orders,
       ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE status IN ('paid', 'shipped', 'delivered')
  AND ordered_at >= '2025-01-01'
GROUP BY month
ORDER BY month;
Run in Planche

Result — first 5 of 12 rows

monthordersrevenue
2025-0152311707.73
2025-0253350775.62
2025-0358351184.96
2025-0452374465.94
2025-0553381541.09

This is the usual pattern for reports grouped by month or week. Here GROUP BY month reuses the SELECT alias. All of these engines allow that in GROUP BY and ORDER BY, but not in WHERE or HAVING.

What you can do now

You can produce a report. What you cannot yet do is compare a row against a summary — "products priced above the average", "the top three in each category" — because that needs two queries at once. The next lesson covers it.

Try it yourself

  1. How many customers are there in each country? Only show countries with at least 100, biggest first.

    Show the answer

    Show the answer

    SELECT country, COUNT(*) AS customers
    FROM customers
    GROUP BY country
    HAVING COUNT(*) >= 100
    ORDER BY customers DESC;
    Run in Planche

    Result — first 5 of 12 rows

    countrycustomers
    KR155
    AU132
    IT130
    ES126
    GB126

    GROUP BY makes one row per country, and the condition is on the count, so it belongs in HAVING rather than WHERE. The expression is repeated there rather than using the alias, which is what works on every engine.

  2. The 15 best-rated products, counting only products with at least 8 reviews.

    Show the answer

    Show the answer

    SELECT p.name,
           COUNT(r.id) AS reviews,
           ROUND(AVG(r.rating), 2) AS avg_rating
    FROM products p
    JOIN reviews r ON r.product_id = p.id
    GROUP BY p.id
    HAVING COUNT(r.id) >= 8
    ORDER BY avg_rating DESC
    LIMIT 15;
    Run in Planche

    Result — first 5 of 15 rows

    namereviewsavg_rating
    Pulse Yoga Mat84.63
    A Winter Letter (3rd ed.)94.56
    Terra Planter84.5
    Nimbus Cosmos84.5
    Ivory Toaster94.33

    Two aggregates over the same group: one counts the reviews, the other averages the rating. The HAVING filter is what stops a single five-star review from topping the list.