SQL GROUP BY and HAVING Tutorial: COUNT, SUM, AVG, ROLLUP and Pivot Reports in MySQL

Totals, averages and counts are the heart of almost every report: sales per branch, students per course, orders per day, the best-selling product this week. In SQL, all of these come from one clause: GROUP BY. Paired with aggregate functions like COUNT, SUM and AVG, and filtered with HAVING, it turns thousands of rows into a short summary you can show on a dashboard.

In this tutorial you will build those reports step by step on a small store database. You will learn the five main aggregate functions, grouping by one or more columns, the difference between WHERE and HAVING, COUNT(DISTINCT), pivot-style reports with SUM(CASE ...), subtotals with WITH ROLLUP, and how to fix the two errors beginners hit most often. Every query was run on MariaDB 11.8 (MySQL-compatible), and the result tables are copied straight from the console.

Step 1: Create the Sample Database

Open phpMyAdmin, MySQL Workbench or the MySQL command line and run this script. It creates a store_reports database with six products and twelve sales from three branches:

  1. -- Sample database for the GROUP BY tutorial (MySQL 8 / MariaDB)
  2. DROP DATABASE IF EXISTS store_reports;
  3. CREATE DATABASE store_reports;
  4. USE store_reports;
  5.  
  6. CREATE TABLE products (
  7.   id INT PRIMARY KEY,
  8.   name VARCHAR(50) NOT NULL,
  9.   category VARCHAR(30) NOT NULL
  10. );
  11.  
  12. CREATE TABLE sales (
  13.   id INT AUTO_INCREMENT PRIMARY KEY,
  14.   sale_date DATE NOT NULL,
  15.   branch VARCHAR(20) NOT NULL,
  16.   cashier VARCHAR(20) NOT NULL,
  17.   product_id INT NOT NULL,
  18.   qty INT NOT NULL,
  19.   unit_price DECIMAL(10,2) NOT NULL,
  20.   FOREIGN KEY (product_id) REFERENCES products(id)
  21. );
  22.  
  23. INSERT INTO products VALUES
  24. (1, 'Coffee 3-in-1', 'Beverages'),
  25. (2, 'Bottled Water', 'Beverages'),
  26. (3, 'Instant Noodles', 'Food'),
  27. (4, 'Canned Tuna', 'Food'),
  28. (5, 'Bath Soap', 'Personal Care'),
  29. (6, 'Shampoo Sachet', 'Personal Care');
  30.  
  31. INSERT INTO sales (sale_date, branch, cashier, product_id, qty, unit_price) VALUES
  32. ('2026-11-02', 'Makati', 'Liza', 1, 10, 12.00),
  33. ('2026-11-02', 'Makati', 'Liza', 3, 6, 15.00),
  34. ('2026-11-02', 'Quezon City', 'Rico', 2, 12, 20.00),
  35. ('2026-11-03', 'Makati', 'Jon', 4, 4, 38.00),
  36. ('2026-11-03', 'Quezon City', 'Rico', 1, 20, 12.00),
  37. ('2026-11-03', 'Quezon City', 'Mae', 5, 3, 45.00),
  38. ('2026-11-04', 'Pasig', 'Ana', 3, 15, 15.00),
  39. ('2026-11-04', 'Pasig', 'Ana', 6, 30, 8.00),
  40. ('2026-11-04', 'Makati', 'Liza', 2, 8, 20.00),
  41. ('2026-11-05', 'Quezon City', 'Mae', 4, 6, 38.00),
  42. ('2026-11-05', 'Pasig', 'Ana', 1, 5, 12.00),
  43. ('2026-11-05', 'Makati', 'Jon', 5, 2, 45.00);

Step 2: Aggregate Functions Without GROUP BY

Aggregate functions take many rows and return one value. Without GROUP BY, the whole table is treated as one group:

  1. SELECT COUNT(*) AS transactions, SUM(qty) AS items_sold,
  2.        SUM(qty * unit_price) AS revenue, AVG(qty * unit_price) AS avg_sale,
  3.        MIN(sale_date) AS first_day, MAX(sale_date) AS last_day
  4. FROM sales;
+--------------+------------+---------+------------+------------+------------+
| transactions | items_sold | revenue | avg_sale   | first_day  | last_day   |
+--------------+------------+---------+------------+------------+------------+
|           12 |        121 | 1980.00 | 165.000000 | 2026-11-02 | 2026-11-05 |
+--------------+------------+---------+------------+------------+------------+
  • COUNT(*) counts rows. COUNT(column) counts only non-NULL values in that column.
  • SUM() and AVG() work on numbers, and here on the expression qty * unit_price.
  • MIN() and MAX() also work on dates and text.

Wrap AVG() in ROUND(AVG(...), 2) if you want fewer decimal places in your report.

Step 3: GROUP BY One Column

Now split the rows into groups, one group per branch, and run the aggregates on each group:

  1. SELECT branch, COUNT(*) AS transactions, SUM(qty * unit_price) AS revenue
  2. FROM sales
  3. GROUP BY branch
  4. ORDER BY revenue DESC;
+-------------+--------------+---------+
| branch      | transactions | revenue |
+-------------+--------------+---------+
| Quezon City |            4 |  843.00 |
| Makati      |            5 |  612.00 |
| Pasig       |            3 |  525.00 |
+-------------+--------------+---------+

Read it as: "for each branch, count the sales and add up the revenue." Always add ORDER BY when the order matters. GROUP BY alone does not promise any order in MySQL 8.

Step 4: GROUP BY Multiple Columns

List more than one column to get one row for every unique combination, here every cashier in every branch:

  1. SELECT branch, cashier, COUNT(*) AS transactions, SUM(qty * unit_price) AS revenue
  2. FROM sales
  3. GROUP BY branch, cashier
  4. ORDER BY branch, revenue DESC;
+-------------+---------+--------------+---------+
| branch      | cashier | transactions | revenue |
+-------------+---------+--------------+---------+
| Makati      | Liza    |            3 |  370.00 |
| Makati      | Jon     |            2 |  242.00 |
| Pasig       | Ana     |            3 |  525.00 |
| Quezon City | Rico    |            2 |  480.00 |
| Quezon City | Mae     |            2 |  363.00 |
+-------------+---------+--------------+---------+

Step 5: GROUP BY with JOIN

The category lives in the products table, so join first and then group. This is the most common pattern in real reports:

  1. SELECT p.category, SUM(s.qty) AS items_sold, SUM(s.qty * s.unit_price) AS revenue
  2. FROM sales s
  3. JOIN products p ON p.id = s.product_id
  4. GROUP BY p.category
  5. ORDER BY revenue DESC;
+---------------+------------+---------+
| category      | items_sold | revenue |
+---------------+------------+---------+
| Beverages     |         55 |  820.00 |
| Food          |         31 |  695.00 |
| Personal Care |         35 |  465.00 |
+---------------+------------+---------+

If JOIN is still new to you, read our SQL JOINs tutorial first.

Step 6: Filter Groups with HAVING

WHERE filters rows before grouping. HAVING filters groups after grouping, so it can use aggregate results. Here we keep only branches with more than 600 in revenue:

  1. SELECT branch, SUM(qty * unit_price) AS revenue
  2. FROM sales
  3. GROUP BY branch
  4. HAVING SUM(qty * unit_price) > 600
  5. ORDER BY revenue DESC;
+-------------+---------+
| branch      | revenue |
+-------------+---------+
| Quezon City |  843.00 |
| Makati      |  612.00 |
+-------------+---------+

Pasig (525.00) was removed because its group total did not pass the HAVING condition.

Step 7: WHERE and HAVING Together

You can use both in one query. This one keeps only beverage rows (WHERE), totals them per branch, then keeps branches with at least 200 in beverage sales (HAVING):

  1. SELECT branch, SUM(qty * unit_price) AS beverage_revenue
  2. FROM sales s
  3. JOIN products p ON p.id = s.product_id
  4. WHERE p.category = 'Beverages'
  5. GROUP BY branch
  6. HAVING beverage_revenue >= 200
  7. ORDER BY beverage_revenue DESC;
+-------------+------------------+
| branch      | beverage_revenue |
+-------------+------------------+
| Quezon City |           480.00 |
| Makati      |           280.00 |
+-------------+------------------+

Pasig sold only 60.00 worth of beverages, so it is filtered out. Notice that MySQL and MariaDB let you use the alias beverage_revenue inside HAVING. That is convenient, but some other databases, such as PostgreSQL and SQL Server, require you to repeat the expression.

Step 8: COUNT(DISTINCT) for Unique Values

To count unique values inside each group, use COUNT(DISTINCT column):

  1. SELECT sale_date, COUNT(*) AS transactions, COUNT(DISTINCT cashier) AS cashiers_on_duty,
  2.        COUNT(DISTINCT branch) AS branches
  3. FROM sales
  4. GROUP BY sale_date
  5. ORDER BY sale_date;
+------------+--------------+------------------+----------+
| sale_date  | transactions | cashiers_on_duty | branches |
+------------+--------------+------------------+----------+
| 2026-11-02 |            3 |                2 |        2 |
| 2026-11-03 |            3 |                3 |        2 |
| 2026-11-04 |            3 |                2 |        2 |
| 2026-11-05 |            3 |                3 |        3 |
+------------+--------------+------------------+----------+

Every day had three transactions, but the number of different cashiers and branches changes from day to day.

Step 9: Pivot-Style Report with SUM(CASE ...)

Managers often want categories as columns instead of rows. Conditional aggregation does that without any special pivot feature:

  1. SELECT branch,
  2.        SUM(CASE WHEN p.category = 'Beverages' THEN s.qty * s.unit_price ELSE 0 END) AS beverages,
  3.        SUM(CASE WHEN p.category = 'Food' THEN s.qty * s.unit_price ELSE 0 END) AS food,
  4.        SUM(CASE WHEN p.category = 'Personal Care' THEN s.qty * s.unit_price ELSE 0 END) AS personal_care,
  5.        SUM(s.qty * s.unit_price) AS total
  6. FROM sales s
  7. JOIN products p ON p.id = s.product_id
  8. GROUP BY branch
  9. ORDER BY total DESC;
+-------------+-----------+--------+---------------+--------+
| branch      | beverages | food   | personal_care | total  |
+-------------+-----------+--------+---------------+--------+
| Quezon City |    480.00 | 228.00 |        135.00 | 843.00 |
| Makati      |    280.00 | 242.00 |         90.00 | 612.00 |
| Pasig       |     60.00 | 225.00 |        240.00 | 525.00 |
+-------------+-----------+--------+---------------+--------+

Each CASE returns the sale amount only for one category and 0 for everything else, so each SUM becomes a separate column. The same trick works for "paid vs unpaid", "passed vs failed" or monthly columns.

Step 10: Subtotals and Grand Total with WITH ROLLUP

WITH ROLLUP adds extra summary rows: a subtotal for each branch and a grand total at the end. The subtotal rows show NULL in the columns being rolled up:

  1. SELECT branch, cashier, SUM(qty * unit_price) AS revenue
  2. FROM sales
  3. GROUP BY branch, cashier WITH ROLLUP;
+-------------+---------+---------+
| branch      | cashier | revenue |
+-------------+---------+---------+
| Makati      | Jon     |  242.00 |
| Makati      | Liza    |  370.00 |
| Makati      | NULL    |  612.00 |
| Pasig       | Ana     |  525.00 |
| Pasig       | NULL    |  525.00 |
| Quezon City | Mae     |  363.00 |
| Quezon City | Rico    |  480.00 |
| Quezon City | NULL    |  843.00 |
| NULL        | NULL    | 1980.00 |
+-------------+---------+---------+

The rows with cashier = NULL are branch subtotals, and the last row is the grand total of 1980.00, which matches Step 2. In MySQL 8 you can use the GROUPING() function to label those rows, for example as "All cashiers".

Bonus: Top 3 Best-Selling Products

  1. SELECT p.name, SUM(s.qty) AS items_sold
  2. FROM sales s
  3. JOIN products p ON p.id = s.product_id
  4. GROUP BY p.id, p.name
  5. ORDER BY items_sold DESC
  6. LIMIT 3;
+-----------------+------------+
| name            | items_sold |
+-----------------+------------+
| Coffee 3-in-1   |         35 |
| Shampoo Sachet  |         30 |
| Instant Noodles |         21 |
+-----------------+------------+

Group by the product id and name so two products with the same name are never merged by accident.

Common Errors and How to Fix Them

1. "Invalid use of group function"

This happens when you put an aggregate inside WHERE:

  1. SELECT branch, SUM(qty * unit_price) AS revenue
  2. FROM sales
  3. WHERE SUM(qty * unit_price) > 600
  4. GROUP BY branch;
ERROR 1111 (HY000) at line 1: Invalid use of group function

Fix: move the condition to HAVING, as in Step 6. WHERE runs before the groups exist, so it cannot know a group's total.

2. "isn't in GROUP BY" (ONLY_FULL_GROUP_BY)

MySQL 5.7 and 8.x enable ONLY_FULL_GROUP_BY by default. In that mode, every selected column must be either in the GROUP BY list or inside an aggregate. To reproduce it on MariaDB, we turned the mode on for the session first:

  1. SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY';
  2. SELECT branch, cashier, SUM(qty * unit_price) AS revenue
  3. FROM sales
  4. GROUP BY branch;
ERROR 1055 (42000) at line 2: 'store_reports.sales.cashier' isn't in GROUP BY

A branch has several cashiers, so the database cannot know which one to show. On MySQL the message is longer and mentions error 1055 and only_full_group_by, but the meaning is the same. Fix: add the column to GROUP BY (as in Step 4), wrap it in an aggregate such as MAX(cashier) or GROUP_CONCAT(DISTINCT cashier), or remove it. Turning the mode off hides the problem and can give you random values, so fix the query instead.

Quick Reference: Order of a GROUP BY Query

SELECT   columns, aggregates
FROM     table
JOIN     other tables
WHERE    row filter (before grouping)
GROUP BY grouping columns
HAVING   group filter (after grouping)
ORDER BY sort
LIMIT    number of rows

Conclusion

With GROUP BY and a handful of aggregate functions you can build almost every summary a dashboard needs: totals per branch, counts per day, top products and category breakdowns. Remember the key rule: WHERE filters rows, HAVING filters groups, and every non-aggregated column in SELECT belongs in GROUP BY. Download the SQL files below, run them on your own MySQL or MariaDB server, and try writing a "sales per day per branch" report as practice. Happy coding!