SQLab Hub
Lesson 8 · Advanced GROUP BY
Lesson 8 · Fundamentals

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.

Multi-column grouping COUNT DISTINCT · NULLs · subtotals ▶ Runnable Playground examples 🧪 5 practice exercises 📝 6-question quiz ⏱ ~20 min

GROUP BY Multiple Columns

Grouping by one column gives one row per value:

SQLite · runs in the Playground
SELECT category, COUNT(*) AS products
FROM products
GROUP BY category
ORDER BY category
LIMIT 4;

▶ Run this query in the SQL Playground

Products per category
categoryproducts
Automotive11
Books16
Clothing9
Electronics7
✓ 4 rows · real output from the Playground database

Add a second column and every combination becomes its own group. Products per category and status:

SQLite · runs in the Playground
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

Products per category and status
categorystatusproducts
Automotiveactive11
Booksactive15
Booksdiscontinued1
Clothingactive9
Electronicsactive7
Food & Beverageactive16
Food & Beveragediscontinued1
Health & Beautyactive9
✓ 8 rows · real output from the Playground database

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.

SQL · syntax
SELECT col_a, col_b, AGGREGATE(col_c)
FROM table
GROUP BY col_a, col_b;
The rule. Every column in SELECT must either be listed in GROUP BY or be inside an aggregate. The order of the GROUP BY columns does not change which groups you get, only how they are usually displayed; ORDER BY decides the final order.

Common Multi-Column Groupings

Orders per year and status

SQLite · runs in the Playground
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

Orders and revenue per year and status
yearstatusordersrevenue
2022delivered2465652.19
2022pending1324466.69
2022processing821959.6
2022cancelled722276.02
2022refunded618201.44
2022shipped412506.92
2023delivered3070494.51
2023cancelled1331188.42
✓ 8 rows · real output from the Playground database

Employees per department and city

SQLite · runs in the Playground
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

Engineering employees per city
departmentcityemployees
EngineeringHouston4
EngineeringFort Worth2
EngineeringJacksonville2
EngineeringPhiladelphia1
EngineeringSan Diego1
EngineeringSan Jose1
✓ 6 rows · real output from the Playground database

Three columns

More columns mean smaller groups. Payments per method, status and year:

SQLite · runs in the Playground
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

Credit card payments per status and year
methodstatusyearpayments
Credit Cardsuccess20221
Credit Cardsuccess20234
Credit Cardfailed20241
Credit Cardrefunded20241
Credit Cardsuccess20247
✓ 5 rows · real output from the Playground database

Several Aggregates in One Query

Each group can carry as many aggregates as you need. A salary overview per department:

SQLite · runs in the Playground
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

Salary statistics per department
departmentemployeeslowesthighestaveragepayroll
Customer Success15617742039991341402012099
Legal16729751922201128661805856
Operations13543872034671268791649429
Marketing13629102044491241111613441
HR12503601910371241511489813
Engineering11514651990291310281441308
Design10460441945431339651339649
Sales11521641957591168931285822
Finance10504631960321214711214710
Product9649301882731191531072374
✓ 10 rows · real output from the Playground database

COUNT(DISTINCT) per group

COUNT(DISTINCT col) counts different values inside each group. Orders and distinct customers per shipping city:

SQLite · runs in the Playground
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

Orders and distinct customers per city
shipping_cityorderscustomers
Jacksonville1515
Los Angeles1414
New York1212
Columbus1211
Washington DC1111
Seattle1111
✓ 6 rows · real output from the Playground database

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:

SQLite · runs in the Playground
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

NULL shipped dates per status
statusorderswith_shipped_datefirst_shipment
delivered82822023-01-08
processing280NULL
cancelled270NULL
pending230NULL
shipped21212023-01-09
refunded190NULL
✓ 6 rows · real output from the Playground database

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:

SQLite · runs in the Playground
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

Employees per manager, including the NULL group
manager_idemployees
NULL10
111
213
311
✓ 4 rows · real output from the Playground database

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:

SQLite · runs in the Playground
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 and status groups with more than 10 products
categorystatusproducts
Food & Beverageactive16
Booksactive15
Office Suppliesactive14
Toysactive12
Automotiveactive11
Sports & Outdoorsactive11
✓ 6 rows · real output from the Playground database

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:

SQL · not supported in SQLite
-- 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:

SQLite · runs in the Playground
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

Orders per status plus a grand total
statusorders
refunded19
shipped21
pending23
cancelled27
processing28
delivered82
All orders200
✓ 7 rows · real output from the Playground database

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

❌ Error in most databases
SQL
SELECT category, status, COUNT(*)
FROM products
GROUP BY category;
✅ Group by it too
SQL
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

❌ Groups of one row
SQL
SELECT department, city, title, COUNT(*)
FROM employees
GROUP BY department, city, title;
✅ Only the columns you report on
SQL
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

❌ January of all years together
SQL
GROUP BY strftime('%m', order_date)
✅ Year and month
SQL
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(*)

❌ Skips NULL rows
SQL
SELECT status, COUNT(shipped_date) AS orders
FROM orders GROUP BY status;
✅ Counts every row
SQL
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

❌ Error
SQL
SELECT category, COUNT(*)
FROM products
WHERE COUNT(*) > 10
GROUP BY category;
✅ HAVING
SQL
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.

  1. 1Easy Count users per country and premium status. Table: users

    Show solution
    SQLite · runs in the Playground
    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.

  2. 2Easy For each rating, show the number of reviews and the average helpful votes. Table: reviews

    Show solution
    SQLite · runs in the Playground
    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.

  3. 3Medium Show revenue per year and month for 2024, excluding cancelled and refunded orders. Table: orders

    Show solution
    SQLite · runs in the Playground
    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.

  4. 4Medium Show units sold and the number of different products sold per category. Table: order_items, products

    Show solution
    SQLite · runs in the Playground
    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.

  5. 5Hard List the department and city combinations with at least 3 employees, with their average salary. Table: employees

    Show solution
    SQLite · runs in the Playground
    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

Question 1 of 6

What does GROUP BY category, status produce compared with GROUP BY category alone?

Question 2 of 6

What is the difference between COUNT(*) and COUNT(column)?

Question 3 of 6

What does COUNT(DISTINCT user_id) return?

Question 4 of 6

A column has the values 90, NULL, 120, NULL, 150. What does AVG return?

Question 5 of 6

Which query shows the cheapest and most expensive product per category?

Question 6 of 6

You add a third column to GROUP BY. What happens to the output?

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.