SQL GROUP BY Multiple Columns: Examples and Practice
GROUP BY with several columns makes one group for each combination of their values. Grouping orders by year and status gives a row for 2023-delivered, 2023-cancelled and so on. This lesson builds on GROUP BY with several columns, several aggregates in one query, COUNT(DISTINCT), NULLs and subtotals.
GROUP BY Multiple Columns
Grouping by one column gives one row per value:
SELECT category, COUNT(*) AS products
FROM products
GROUP BY category
ORDER BY category
LIMIT 4;
▶ Run this query in the SQL Playground
| category | products |
|---|---|
| Automotive | 11 |
| Books | 16 |
| Clothing | 9 |
| Electronics | 7 |
Add a second column and every combination becomes its own group. Products per category and status:
SELECT category, status, COUNT(*) AS products
FROM products
GROUP BY category, status
ORDER BY category, status
LIMIT 8;
▶ Run this query in the SQL Playground
| category | status | products |
|---|---|---|
| Automotive | active | 11 |
| Books | active | 15 |
| Books | discontinued | 1 |
| Clothing | active | 9 |
| Electronics | active | 7 |
| Food & Beverage | active | 16 |
| Food & Beverage | discontinued | 1 |
| Health & Beauty | active | 9 |
Only combinations that occur in the data appear. A category with no discontinued products has no "discontinued" row; it is not shown with a 0.
SELECT col_a, col_b, AGGREGATE(col_c)
FROM table
GROUP BY col_a, col_b;
Common Multi-Column Groupings
Orders per year and status
SELECT strftime('%Y', order_date) AS year,
status,
COUNT(*) AS orders,
ROUND(SUM(total), 2) AS revenue
FROM orders
GROUP BY year, status
ORDER BY year, orders DESC
LIMIT 8;
▶ Run this query in the SQL Playground
| year | status | orders | revenue |
|---|---|---|---|
| 2022 | delivered | 24 | 65652.19 |
| 2022 | pending | 13 | 24466.69 |
| 2022 | processing | 8 | 21959.6 |
| 2022 | cancelled | 7 | 22276.02 |
| 2022 | refunded | 6 | 18201.44 |
| 2022 | shipped | 4 | 12506.92 |
| 2023 | delivered | 30 | 70494.51 |
| 2023 | cancelled | 13 | 31188.42 |
Employees per department and city
SELECT department, city, COUNT(*) AS employees
FROM employees
WHERE department = 'Engineering'
GROUP BY department, city
ORDER BY employees DESC, city;
▶ Run this query in the SQL Playground
| department | city | employees |
|---|---|---|
| Engineering | Houston | 4 |
| Engineering | Fort Worth | 2 |
| Engineering | Jacksonville | 2 |
| Engineering | Philadelphia | 1 |
| Engineering | San Diego | 1 |
| Engineering | San Jose | 1 |
Three columns
More columns mean smaller groups. Payments per method, status and year:
SELECT method, status, strftime('%Y', paid_at) AS year, COUNT(*) AS payments
FROM payments
WHERE method = 'Credit Card'
GROUP BY method, status, year
ORDER BY year, status;
▶ Run this query in the SQL Playground
| method | status | year | payments |
|---|---|---|---|
| Credit Card | success | 2022 | 1 |
| Credit Card | success | 2023 | 4 |
| Credit Card | failed | 2024 | 1 |
| Credit Card | refunded | 2024 | 1 |
| Credit Card | success | 2024 | 7 |
Several Aggregates in One Query
Each group can carry as many aggregates as you need. A salary overview per department:
SELECT department,
COUNT(*) AS employees,
MIN(salary) AS lowest,
MAX(salary) AS highest,
ROUND(AVG(salary)) AS average,
SUM(salary) AS payroll
FROM employees
GROUP BY department
ORDER BY payroll DESC;
▶ Run this query in the SQL Playground
| department | employees | lowest | highest | average | payroll |
|---|---|---|---|---|---|
| Customer Success | 15 | 61774 | 203999 | 134140 | 2012099 |
| Legal | 16 | 72975 | 192220 | 112866 | 1805856 |
| Operations | 13 | 54387 | 203467 | 126879 | 1649429 |
| Marketing | 13 | 62910 | 204449 | 124111 | 1613441 |
| HR | 12 | 50360 | 191037 | 124151 | 1489813 |
| Engineering | 11 | 51465 | 199029 | 131028 | 1441308 |
| Design | 10 | 46044 | 194543 | 133965 | 1339649 |
| Sales | 11 | 52164 | 195759 | 116893 | 1285822 |
| Finance | 10 | 50463 | 196032 | 121471 | 1214710 |
| Product | 9 | 64930 | 188273 | 119153 | 1072374 |
COUNT(DISTINCT) per group
COUNT(DISTINCT col) counts different values inside each group. Orders and distinct customers per shipping city:
SELECT shipping_city,
COUNT(*) AS orders,
COUNT(DISTINCT user_id) AS customers
FROM orders
GROUP BY shipping_city
ORDER BY orders DESC
LIMIT 6;
▶ Run this query in the SQL Playground
| shipping_city | orders | customers |
|---|---|---|
| Jacksonville | 15 | 15 |
| Los Angeles | 14 | 14 |
| New York | 12 | 12 |
| Columbus | 12 | 11 |
| Washington DC | 11 | 11 |
| Seattle | 11 | 11 |
When orders is larger than customers, some customers ordered more than once to that city.
How NULLs Are Grouped and Counted
Two rules. First, GROUP BY puts all NULLs in one group. Second, aggregates other than COUNT(*) skip NULLs. Both show up when grouping orders by whether they shipped:
SELECT status,
COUNT(*) AS orders,
COUNT(shipped_date) AS with_shipped_date,
MIN(shipped_date) AS first_shipment
FROM orders
GROUP BY status
ORDER BY orders DESC;
▶ Run this query in the SQL Playground
| status | orders | with_shipped_date | first_shipment |
|---|---|---|---|
| delivered | 82 | 82 | 2023-01-08 |
| processing | 28 | 0 | NULL |
| cancelled | 27 | 0 | NULL |
| pending | 23 | 0 | NULL |
| shipped | 21 | 21 | 2023-01-09 |
| refunded | 19 | 0 | NULL |
For pending, processing, cancelled and refunded orders, COUNT(shipped_date) is 0 and MIN is NULL: there are no values to work with. Grouping by a column that contains NULLs gives one extra group for them:
SELECT manager_id, COUNT(*) AS employees
FROM employees
GROUP BY manager_id
ORDER BY manager_id
LIMIT 4;
▶ Run this query in the SQL Playground
| manager_id | employees |
|---|---|
| NULL | 10 |
| 1 | 11 |
| 2 | 13 |
| 3 | 11 |
The first row, with NULL, holds the 10 employees who have no manager. AVG over a group also ignores NULLs, so it averages only the values that exist; wrap the column in COALESCE if missing values should count as 0.
Filtering Groups With HAVING
HAVING filters the groups after aggregation. Category and status combinations with more than 10 products:
SELECT category, status, COUNT(*) AS products
FROM products
GROUP BY category, status
HAVING COUNT(*) > 10
ORDER BY products DESC;
▶ Run this query in the SQL Playground
| category | status | products |
|---|---|---|
| Food & Beverage | active | 16 |
| Books | active | 15 |
| Office Supplies | active | 14 |
| Toys | active | 12 |
| Automotive | active | 11 |
| Sports & Outdoors | active | 11 |
Use WHERE for conditions on single rows (they run first and shrink the groups) and HAVING for conditions on aggregates. See HAVING.
Subtotals and Grand Totals
Reports often want a total per group plus a grand total. PostgreSQL, SQL Server, Oracle and MySQL have ROLLUP for this:
-- PostgreSQL, SQL Server, Oracle (MySQL: GROUP BY status WITH ROLLUP)
SELECT status, COUNT(*) AS orders
FROM orders
GROUP BY ROLLUP (status);
SQLite has no ROLLUP, but UNION ALL gives the same result:
SELECT status, COUNT(*) AS orders
FROM orders
GROUP BY status
UNION ALL
SELECT 'All orders', COUNT(*)
FROM orders
ORDER BY orders;
▶ Run this query in the SQL Playground
| status | orders |
|---|---|
| refunded | 19 |
| shipped | 21 |
| pending | 23 |
| cancelled | 27 |
| processing | 28 |
| delivered | 82 |
| All orders | 200 |
With two columns, ROLLUP (year, status) adds a subtotal per year and a grand total; CUBE adds subtotals for every combination; GROUPING SETS lets you list exactly which groupings you want.
Common Multi-Column GROUP BY Mistakes
Five errors and how to avoid them.
1. A selected column missing from GROUP BY
SELECT category, status, COUNT(*)
FROM products
GROUP BY category;
SELECT category, status, COUNT(*)
FROM products
GROUP BY category, status;
A category has several statuses, so there is no single status to show. SQLite returns an arbitrary one instead of an error, which hides the bug.
2. Grouping by too many columns
SELECT department, city, title, COUNT(*)
FROM employees
GROUP BY department, city, title;
SELECT department, COUNT(*)
FROM employees
GROUP BY department;
Each extra column splits the groups further. With unique combinations every count is 1.
3. Grouping by month without the year
GROUP BY strftime('%m', order_date)
GROUP BY strftime('%Y', order_date), strftime('%m', order_date)
The Playground has three years of orders; grouping by month alone merges them.
4. COUNT(column) when you meant COUNT(*)
SELECT status, COUNT(shipped_date) AS orders
FROM orders GROUP BY status;
SELECT status, COUNT(*) AS orders
FROM orders GROUP BY status;
Unshipped orders have NULL shipped_date, so the first query reports 0 pending orders.
5. Filtering an aggregate in WHERE
SELECT category, COUNT(*)
FROM products
WHERE COUNT(*) > 10
GROUP BY category;
SELECT category, COUNT(*)
FROM products
GROUP BY category
HAVING COUNT(*) > 10;
WHERE runs before the groups exist.
Practice Advanced GROUP BY Queries
5 exercises on the real Playground tables.
Write each query yourself first. Every solution has been run against the Playground database.
-
1Easy Count users per country and premium status. Table:
usersShow solution
SELECT country, is_premium, COUNT(*) AS users FROM users GROUP BY country, is_premium ORDER BY country, is_premium;▶ Run this query in the SQL Playground
Each country appears once per premium value that exists for it.
-
2Easy For each rating, show the number of reviews and the average helpful votes. Table:
reviewsShow solution
SELECT rating, COUNT(*) AS reviews, ROUND(AVG(helpful_votes), 1) AS avg_votes FROM reviews GROUP BY rating ORDER BY rating DESC;▶ Run this query in the SQL Playground
Two aggregates per group.
-
3Medium Show revenue per year and month for 2024, excluding cancelled and refunded orders. Table:
ordersShow solution
SELECT strftime('%Y', order_date) AS year, strftime('%m', order_date) AS month, ROUND(SUM(total), 2) AS revenue FROM orders WHERE order_date >= '2024-01-01' AND status NOT IN ('cancelled', 'refunded') GROUP BY year, month ORDER BY year, month;▶ Run this query in the SQL Playground
WHERE removes rows first; the year column keeps the months of different years apart.
-
4Medium Show units sold and the number of different products sold per category. Table:
order_items, productsShow solution
SELECT p.category, SUM(oi.quantity) AS units, COUNT(DISTINCT oi.product_id) AS different_products FROM order_items oi JOIN products p ON p.id = oi.product_id GROUP BY p.category ORDER BY units DESC;▶ Run this query in the SQL Playground
COUNT(DISTINCT) counts each product once per category even if it was ordered many times.
-
5Hard List the department and city combinations with at least 3 employees, with their average salary. Table:
employeesShow solution
SELECT department, city, COUNT(*) AS employees, ROUND(AVG(salary)) AS avg_salary FROM employees GROUP BY department, city HAVING COUNT(*) >= 3 ORDER BY employees DESC, avg_salary DESC;▶ Run this query in the SQL Playground
HAVING keeps only the larger combinations.
📝 Quiz — Advanced GROUP BY
Test your understanding · 0/6 answered
What does GROUP BY category, status produce compared with GROUP BY category alone?
Each existing combination becomes a group.
What is the difference between COUNT(*) and COUNT(column)?
COUNT(shipped_date) is 0 for pending orders.
What does COUNT(DISTINCT user_id) return?
Each customer is counted once.
A column has the values 90, NULL, 120, NULL, 150. What does AVG return?
NULLs are left out of both the sum and the count.
Which query shows the cheapest and most expensive product per category?
GROUP BY category gives one row per category with both aggregates.
You add a third column to GROUP BY. What happens to the output?
Groups can only get smaller as columns are added.
Advanced GROUP BY Interview Questions
Q1. What does GROUP BY a, b return?
One row for each combination of a and b that exists in the data, with aggregates computed over the rows in that combination.
Q2. How are NULLs grouped?
All NULLs in a grouping column go into a single group. Aggregates other than COUNT(*) ignore NULL values inside each group.
Q3. How do you add subtotals and a grand total?
With GROUP BY ROLLUP (PostgreSQL, SQL Server, Oracle; WITH ROLLUP in MySQL), CUBE or GROUPING SETS. Without them, combine grouped queries with UNION ALL.
Q4. Does the order of GROUP BY columns matter?
Not for which groups are produced. It matters for ROLLUP, which builds subtotals from left to right, and it is often the natural display order, but use ORDER BY to guarantee the order.
Frequently Asked Questions
Can you GROUP BY multiple columns in SQL?
Yes. GROUP BY col1, col2 creates one group for each combination of values that exists. GROUP BY category, status gives a row for every category and status pair in the data.
What is the difference between COUNT(*) and COUNT(column)?
COUNT(*) counts every row in the group. COUNT(column) counts only rows where that column is not NULL, so it can be smaller.
What does COUNT(DISTINCT column) do?
It counts the different non-NULL values in the column within each group. COUNT(DISTINCT user_id) per city gives the number of different customers per city.
How do AVG and SUM handle NULL values?
They ignore them. AVG divides by the number of non-NULL values, so the average of 90, NULL, 120, NULL and 150 is 120. If a whole group is NULL, SUM and AVG return NULL.
What happens when you add more columns to GROUP BY?
The groups get smaller and there are more of them, because each extra column splits the existing groups by its values.
Where can I practise multi-column GROUP BY?
In the SQLabHub Playground: every query on this page has a "Run this query" link, and the Playground's orders, products and employees tables have plenty of columns to group by.