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;
Result — first 5 of 7 rows
| status | orders |
|---|---|
| delivered | 1351 |
| paid | 695 |
| shipped | 641 |
| pending | 349 |
| refunded | 327 |
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;
Result — 1 row
| customers | with_phone | without_phone |
|---|---|---|
| 1500 | 1221 | 279 |
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;
Result — first 5 of 20 rows
| category | products | cheapest | average | dearest |
|---|---|---|---|---|
| Wearables | 11 | 417.3 | 1672.29 | 2433.73 |
| Programming | 10 | 6.89 | 1585.67 | 2420.02 |
| Tablets | 17 | 314.76 | 1529.45 | 2412.93 |
| Audio | 11 | 593.8 | 1482.89 | 2334.28 |
| Kitchen | 12 | 670.54 | 1443.21 | 2293.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;
Result — first 5 of 27 rows
| category | orders | units | revenue |
|---|---|---|---|
| Watches | 395 | 815 | 933900.92 |
| Board Games | 395 | 801 | 929534.55 |
| Laptops | 366 | 768 | 900778.09 |
| Science | 340 | 709 | 888542.78 |
| Cycling | 405 | 823 | 858478.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;
Result — first 5 of 8 rows
| category | revenue |
|---|---|
| Board Games | 1377454.99 |
| Tablets | 1348572.94 |
| Science | 1347046.09 |
| Watches | 1325717.58 |
| Cycling | 1288143.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;
Result — first 5 of 12 rows
| month | orders | revenue |
|---|---|---|
| 2025-01 | 52 | 311707.73 |
| 2025-02 | 53 | 350775.62 |
| 2025-03 | 58 | 351184.96 |
| 2025-04 | 52 | 374465.94 |
| 2025-05 | 53 | 381541.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.