Chapter 10 — Grouping & Aggregation: GROUP BY & HAVING
1. What is it?
In relational querying, individual rows often contain granular transaction details, while business decisions require higher-level summaries.
GROUP BY: Collapses all rows that share identical values across one or more grouping columns into distinct summary buckets. When combined with aggregate functions (COUNT,SUM,AVG,MIN,MAX), it computes a single calculated metric for each group.HAVING: A dedicated filtering clause designed specifically to filter summary groups after aggregation has occurred.
The Fundamental Distinction: WHERE vs HAVING
WHEREfilters individual raw rows before groups are formed and before aggregate functions are calculated. It cannot reference aggregate expressions (e.g.,WHERE AVG(salary) > 50000is invalid SQL).HAVINGfilters aggregated summary buckets after theGROUP BYclause has processed the dataset. It operates directly on aggregated expressions (e.g.,HAVING AVG(salary) > 50000).
2. Logical Query Execution Order
To write and debug complex SQL queries effectively, you must understand the engine's internal Logical Query Processing Order. Although SQL queries are written starting with SELECT, the database engine processes clauses in an entirely different sequence:
flowchart TD
S1["1. FROM / JOIN (Locate & link tables)"] --> S2["2. WHERE (Filter individual rows)"]
S2 --> S3["3. GROUP BY (Collapse rows into buckets)"]
S3 --> S4["4. HAVING (Filter aggregated buckets)"]
S4 --> S5["5. SELECT (Evaluate projections, aliases, aggregates)"]
S5 --> S6["6. DISTINCT (Deduplicate rows)"]
S6 --> S7["7. ORDER BY (Sort final result set)"]
S7 --> S8["8. LIMIT / OFFSET (Slice output page)"]Because WHERE executes at Step 2 (before groups exist at Step 3), it cannot filter on aggregates! Because HAVING executes at Step 4, it filters the aggregated groups before final projection at Step 5.
3. Syntax
SELECT
group_column1,
group_column2,
AGGREGATE_FUNCTION(metric_column) AS summary_metric
FROM table_name
[WHERE raw_row_filter_condition]
GROUP BY
group_column1,
group_column2
[WITH ROLLUP]
[HAVING aggregate_filter_condition]
[ORDER BY summary_metric DESC]
[LIMIT row_count];Advanced MySQL Aggregation: GROUP_CONCAT
MySQL provides the powerful GROUP_CONCAT() aggregate function, which concatenates non-null values from each group into a single formatted string:
GROUP_CONCAT([DISTINCT] column_name [ORDER BY col ASC] [SEPARATOR ', '])4. Basic Example
Basic grouping, aggregate metrics, and group filtering:
USE sql_mastery;
-- Total number of customers grouped by country
SELECT
country,
COUNT(*) AS total_customers
FROM customers
GROUP BY country;
-- Filter groups using HAVING: Only show countries with more than 1 customer
SELECT
country,
COUNT(*) AS total_customers
FROM customers
GROUP BY country
HAVING COUNT(*) > 1;
-- Aggregate string concatenation: List all customer first names in each country
SELECT
country,
COUNT(*) AS customer_count,
GROUP_CONCAT(first_name ORDER BY first_name ASC SEPARATOR ', ') AS customer_roster
FROM customers
GROUP BY country;5. Real-World Example
The Chief Financial Officer requires a Departmental Payroll & Headcount Analysis from the employees table:
- Group employees by
department_id. - Filter out inactive employees using
WHERE is_active = TRUE. - Compute total headcount, total salary expenditure, average salary, and minimum/maximum salary range.
- Filter out any department with fewer than 2 active employees or an average salary under $80,000 using
HAVING. - Append subtotal and grand total summary rows using
WITH ROLLUP.
USE sql_mastery;
SELECT
IF(GROUPING(department_id) = 1, 'ALL DEPARTMENTS (GRAND TOTAL)', COALESCE(CAST(department_id AS CHAR), 'No Department')) AS department_label,
COUNT(*) AS active_headcount,
SUM(salary) AS total_payroll,
ROUND(AVG(salary), 2) AS average_salary,
MIN(salary) AS min_salary,
MAX(salary) AS max_salary
FROM employees
WHERE is_active = TRUE
GROUP BY department_id WITH ROLLUP
HAVING COUNT(*) >= 2 OR GROUPING(department_id) = 1;6. Step-by-Step Explanation
FROM employees WHERE is_active = TRUE:- The storage engine scans
employeesand immediately discards any employee whoseis_activestatus isFALSE.
- The storage engine scans
GROUP BY department_id WITH ROLLUP:- Rows are sorted and partitioned into distinct buckets based on
department_id(e.g., Dept 1, Dept 2, Dept 3, Dept 4). - The
WITH ROLLUPmodifier instructs MySQL to generate an extra hierarchical summary row representing the grand total across all departments.
- Rows are sorted and partitioned into distinct buckets based on
HAVING COUNT(*) >= 2 OR GROUPING(department_id) = 1:- The engine evaluates the summary metrics for each department bucket.
- Single-member departments (e.g., Dept 5) are filtered out.
- The
OR GROUPING(department_id) = 1clause ensures that the grand-total rollup row is preserved even though individual departments were filtered.
SELECT ... GROUPING(...):- The
GROUPING(department_id)function returns1for the rollup row and0for standard grouped rows, allowing us to display'ALL DEPARTMENTS (GRAND TOTAL)'instead of a confusingNULL.
- The
7. Expected Result
Output of the Departmental Payroll Analysis:
+--------------------------------+------------------+---------------+----------------+------------+------------+
| department_label | active_headcount | total_payroll | average_salary | min_salary | max_salary |
+--------------------------------+------------------+---------------+----------------+------------+------------+
| 1 | 3 | 368000.00 | 122666.67 | 98000.00 | 145000.00 |
| 2 | 2 | 227000.00 | 113500.00 | 92000.00 | 135000.00 |
| 3 | 2 | 208000.00 | 104000.00 | 78000.00 | 130000.00 |
| 4 | 2 | 182000.00 | 91000.00 | 72000.00 | 110000.00 |
| ALL DEPARTMENTS (GRAND TOTAL) | 10 | 1070000.00 | 107000.00 | 72000.00 | 145000.00 |
+--------------------------------+------------------+---------------+----------------+------------+------------+
5 rows in set (0.00 sec)8. Common Mistakes
- The
ONLY_FULL_GROUP_BYError (MySQL 5.7+ / 8.0+):- The Broken Query:sql
SELECT department_id, first_name, AVG(salary) FROM employees GROUP BY department_id; - Fatal Error:
ERROR 1055 (42000): 'sql_mastery.employees.first_name' isn't in GROUP BY clause and contains nonaggregated column... which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by - Why?: If Department 1 has 3 employees (Alex, Sarah, Marcus), which single
first_nameshould the database pick to display beside the department average? Choosing one at random produces non-deterministic data. - Strict Rule: In modern SQL, every column listed in
SELECTmust either appear in theGROUP BYclause or be enclosed inside an aggregate function.
- The Broken Query:
- Placing Aggregate Conditions in
WHERE:- Mistake:
SELECT department_id FROM employees WHERE AVG(salary) > 80000 GROUP BY department_id; - Error:
ERROR 1111 (HY000): Invalid use of group function. - Correction: Move the aggregate filter to
HAVING AVG(salary) > 80000.
- Mistake:
- Placing Non-Aggregate Filters in
HAVING:- Sub-optimal Query:sql
SELECT department_id, AVG(salary) FROM employees GROUP BY department_id HAVING department_id = 1; -- POOR PRACTICE! - Why it's bad: The database engine groups every employee in the entire company first, and only filters out other departments afterward!
- Correction: Filter raw rows using
WHERE department_id = 1before grouping so the engine processes significantly fewer records.
- Sub-optimal Query:
9. Best Practices
- Filter Early with
WHERE, Filter Late withHAVING:- Always eliminate unwanted rows in
WHEREbefore they enter the grouping engine. ReserveHAVINGstrictly for conditions that evaluate aggregate function results (SUM,COUNT,AVG).
- Always eliminate unwanted rows in
- Build Composite Indexes to Accelerate Grouping:
- If you frequently run
SELECT department_id, status, COUNT(*) FROM orders GROUP BY department_id, status, an index on(department_id, status)allows the engine to compute aggregates by traversing the pre-sorted index tree, completely avoiding a temporary table.
- If you frequently run
- Use the
GROUPING()Function withROLLUP:- When using
WITH ROLLUP, never use string functions likeIFNULL(col, 'Total')ifcolcan legitimately contain NULL values. Use the standard ANSI SQLGROUPING(col)function to reliably detect engine-generated summary rows.
- When using
10. Practice Questions
Easy
- Write a query to find the total number of products in each
category_id. - Write a query to calculate the average
unit_pricefor each category. - Write a query to count the total number of orders for each
status.
Medium
- Write a query to calculate the total revenue (
SUM(total_amount)) for each customer inorders, filtering to display only customers whose total purchases exceed $500.00. - Write a query to list each
supplier_idalong with the number of products they supply, and useGROUP_CONCATto list the product names separated by' | '. - Write a query to determine the number of orders placed in each calendar month of 2023.
Difficult
- Write a multi-column aggregation query on
order_itemsthat groups byorder_idand calculates the total quantity of items, the gross total, the average discount, and usesHAVINGto isolate orders that contain more than 1 distinct product line item and have an average discount greater than 0%. - Construct a query against
productsthat groups bycategory_idandsupplier_idusingWITH ROLLUP, usingGROUPING()to provide clean descriptive labels for the subtotals and grand totals.
11. Interview Questions
Q1: What is the fundamental difference between WHERE and HAVING in SQL?
Answer:
- Phase of Execution: In the logical query processing pipeline,
WHEREexecutes before theGROUP BYphase, operating on individual raw table rows.HAVINGexecutes after rows have been collapsed into aggregate buckets. - Expression Capability:
WHEREcan only reference raw column values and scalar expressions; it cannot contain aggregate functions because aggregates do not yet exist whenWHEREevaluates.HAVINGcan filter directly on aggregate expressions (SUM,AVG,COUNT), evaluating conditions across entire groups of rows.
Q2: What is the purpose of ONLY_FULL_GROUP_BY in MySQL, and why should it never be disabled in production?
Answer: ONLY_FULL_GROUP_BY is an SQL mode in MySQL (enabled by default in MySQL 5.7 and 8.0) that conforms strictly to the ANSI SQL standard. It rejects queries where the SELECT list, HAVING condition, or ORDER BY clause references non-aggregated columns that are neither named in the GROUP BY clause nor functionally dependent on them. Disabling it causes MySQL to return an arbitrary, non-deterministic value from one of the grouped rows for any unaggregated column. This introduces silent data integrity bugs, causes different replicas to return different answers, and violates relational consistency.
Q3: How does the WITH ROLLUP modifier work in MySQL, and how do you distinguish between real NULL values and rollup summary NULLs?
Answer: WITH ROLLUP is an extension to the GROUP BY clause that generates hierarchical subtotals and a grand total row by rolling up dimensions from right to left. In the generated summary rows, the rolled-up grouping columns are set to NULL. To distinguish whether a NULL represents a genuine NULL stored in the database row or a summary marker generated by ROLLUP, SQL provides the GROUPING(column_name) function. GROUPING() returns 1 when the NULL was generated as a rollup aggregate marker, and 0 when the row represents a genuine data value.
12. Quick Revision
GROUP BYcollapses rows with matching keys into aggregate summary buckets.WHEREfilters rows before grouping;HAVINGfilters aggregated buckets after grouping.ONLY_FULL_GROUP_BYrequires every selected column to be either in theGROUP BYclause or wrapped in an aggregate function.- Use
GROUP_CONCAT()to concatenate string values within a group into a single delimited row. WITH ROLLUPcomputes multi-level subtotals and grand totals; useGROUPING()to format the generated summary rows cleanly.