GROUP BY
GROUP BY splits rows into groups that share a value, then calculates aggregates for each group: revenue per month, employees per department, average price per category. It turns a list of facts into a summary table.
Grouping by one column
SELECT category, COUNT(*) AS products, round(AVG(price), 2) AS avg_price FROM products GROUP BY category;
| category | products | avg_price |
|---|---|---|
| Books | 3 | 34.13 |
| Electronics | 4 | 45.99 |
| Home | 3 | 31.83 |
| Toys | 2 | 28.95 |
Each distinct category becomes one row, and the aggregates are calculated per group.
How it works
FROMandWHEREpick the rows.GROUP BYsorts them into buckets by the grouping value.- Each bucket becomes one output row; aggregates summarise the bucket.
The rule that follows: every column in the select list must either be in GROUP BY or be inside an aggregate. Otherwise there's no single value to show for the group.
Grouping with WHERE and ORDER BY
SELECT status, COUNT(*) AS orders FROM orders WHERE order_date >= '2026-01-01' GROUP BY status ORDER BY orders DESC;
| status | orders |
|---|---|
| pending | 3 |
| shipped | 2 |
| delivered | 2 |
Grouping by several columns
SELECT country, city, COUNT(*) AS customers FROM customers GROUP BY country, city ORDER BY country, city;
| country | city | customers |
|---|---|---|
| Czechia | Prague | 1 |
| France | NULL | 1 |
| France | Lyon | 1 |
| Germany | Berlin | 1 |
| Portugal | Lisbon | 2 |
| Portugal | Porto | 1 |
| Spain | Madrid | 1 |
| UK | London | 1 |
| UK | Manchester | 1 |
A group is formed for each distinct combination. NULLs are grouped together, so Farah gets a France / NULL group.
Grouping by an expression
SELECT strftime('%Y', hire_date) AS hire_year, COUNT(*) AS hires FROM employees GROUP BY hire_year ORDER BY hire_year;
| hire_year | hires |
|---|---|
| 2015 | 1 |
| 2016 | 1 |
| 2017 | 1 |
| 2018 | 1 |
| 2019 | 2 |
| 2020 | 1 |
| 2021 | 1 |
| 2022 | 1 |
| 2023 | 1 |
| 2024 | 2 |
Grouping by a calculated value — a year, a price band, the first letter of a name — is common. Standard SQL requires you to repeat the expression in GROUP BY; SQLite, PostgreSQL and MySQL also accept the alias.
SELECT CASE WHEN price < 20 THEN 'under 20' WHEN price < 50 THEN '20–49' ELSE '50+' END AS band, COUNT(*) AS products FROM products GROUP BY band ORDER BY MIN(price);
| band | products |
|---|---|
| under 20 | 4 |
| 20–49 | 6 |
| 50+ | 2 |
Revenue per order
SELECT order_id, COUNT(*) AS lines, SUM(quantity) AS units, round(SUM(quantity * unit_price), 2) AS order_total FROM order_items GROUP BY order_id ORDER BY order_total DESC LIMIT 5;
| order_id | lines | units | order_total |
|---|---|---|---|
| 10 | 1 | 2 | 119.98 |
| 7 | 2 | 2 | 108.99 |
| 4 | 2 | 5 | 99 |
| 15 | 1 | 2 | 98 |
| 12 | 1 | 3 | 97.5 |
Monthly report
SELECT strftime('%Y-%m', o.order_date) AS month, COUNT(DISTINCT o.id) AS orders, round(SUM(oi.quantity * oi.unit_price), 2) AS revenue FROM orders AS o JOIN order_items AS oi ON oi.order_id = o.id WHERE o.status <> 'cancelled' GROUP BY month ORDER BY month;
| month | orders | revenue |
|---|---|---|
| 2025-09 | 2 | 109.89 |
| 2025-10 | 2 | 171.48 |
| 2025-11 | 1 | 81 |
| 2025-12 | 2 | 148.89 |
| 2026-01 | 2 | 203.78 |
| 2026-02 | 3 | 162 |
| 2026-03 | 2 | 177.98 |
This preview uses a join to combine orders with their items — joins are explained from lesson 23. Notice COUNT(DISTINCT o.id): after the join, an order with two items appears twice.
Try it yourself
Show each department's number of employees, total payroll and highest salary.