GROUP BY in MySQL
GROUP BY divides rows into groups and lets you apply aggregate functions to each group
independently. It transforms a detail-level query into a summary query — the essential step for
building reports, dashboards, and analytics.
Query Execution Order
Understanding the logical order in which MySQL processes a query is critical for writing correct GROUP BY queries. The order is not the order clauses appear in the SQL text:
Step | Clause | What happens | Can reference aggregates? |
|---|---|---|---|
1 | FROM / JOIN | Build the full row set | No |
2 | WHERE | Filter individual rows (before grouping) | No |
3 | GROUP BY | Collapse rows into groups | N/A |
4 | HAVING | Filter groups (after grouping) | Yes |
5 | SELECT | Compute output expressions | Yes |
6 | ORDER BY | Sort the result | Yes |
7 | LIMIT / OFFSET | Trim to requested rows | N/A |
-- Correct: WHERE filters rows before grouping, HAVING filters groups after SELECT status, COUNT(*) AS cnt, SUM(total) AS revenue FROM orders WHERE created_at >= '2024-01-01' -- Step 2: remove old rows first GROUP BY status -- Step 3: group remaining rows HAVING revenue > 10000 -- Step 4: keep only high-revenue groups ORDER BY revenue DESC; -- Step 6: sort
WHERE for conditions on individual rows (runs before grouping — faster, can use indexes). Use HAVING for conditions on aggregated groups (runs after grouping).GROUP BY Syntax
-- Count orders per status SELECT status, COUNT(*) AS order_count FROM orders GROUP BY status; -- Sum revenue per customer SELECT customer_id, ROUND(SUM(total), 2) AS total_spent FROM orders WHERE status = 'delivered' GROUP BY customer_id ORDER BY total_spent DESC;
ONLY_FULL_GROUP_BY — Strict Mode Deep-Dive
MySQL 5.7+ enables ONLY_FULL_GROUP_BY by default. This strict mode requires that every non-aggregate column in the SELECT list be listed in GROUP BY (or be functionally dependent on the GROUP BY columns).
Why it exists: without it, MySQL picks an arbitrary value from the group for any non-grouped column, leading to unpredictable, non-deterministic query results. The strict mode prevents silent bugs.
-- Check the current SQL mode SELECT @@sql_mode; -- ERROR in ONLY_FULL_GROUP_BY mode: -- 'first_name' is not in GROUP BY and is not an aggregate SELECT customer_id, first_name, COUNT(*) AS orders FROM orders JOIN customers USING (customer_id) GROUP BY customer_id; -- ERROR 1055: Expression #2 of SELECT list is not in GROUP BY clause -- Fix Option 1: add first_name to GROUP BY SELECT customer_id, first_name, COUNT(*) AS orders FROM orders JOIN customers USING (customer_id) GROUP BY customer_id, first_name ORDER BY orders DESC; -- Fix Option 2: use ANY_VALUE() to explicitly pick one value (MySQL 5.7+) -- Use when you know the value is the same for all rows in the group SELECT customer_id, ANY_VALUE(first_name) AS first_name, COUNT(*) AS orders FROM orders JOIN customers USING (customer_id) GROUP BY customer_id; -- Fix Option 3: use an aggregate that makes the intent clear SELECT customer_id, MIN(first_name) AS first_name, COUNT(*) AS orders FROM orders JOIN customers USING (customer_id) GROUP BY customer_id;
ONLY_FULL_GROUP_BY to silence errors is a bad practice — it hides bugs where non-deterministic values are returned for non-grouped columns. Fix the query instead.Grouping by Multiple Columns
-- Revenue per country AND per status SELECT country, status, COUNT(*) AS orders, ROUND(SUM(o.total), 2) AS revenue FROM orders AS o JOIN customers AS c USING (customer_id) GROUP BY country, status ORDER BY country, status;
Grouping by Expressions
You can group by any expression — date functions, CASE statements, arithmetic — not just plain column names.
-- Group by calendar month
SELECT
DATE_FORMAT(created_at, '%Y-%m') AS month,
COUNT(*) AS orders,
ROUND(SUM(total), 2) AS revenue
FROM orders
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ORDER BY month;
-- Group by week using YEARWEEK
SELECT
YEARWEEK(created_at, 1) AS iso_week,
MIN(DATE(created_at)) AS week_start,
COUNT(*) AS orders
FROM orders
GROUP BY YEARWEEK(created_at, 1)
ORDER BY iso_week;
-- Group by price bucket using CASE
SELECT
CASE
WHEN price < 25 THEN 'Budget (under $25)'
WHEN price BETWEEN 25 AND 99 THEN 'Mid-range ($25-$99)'
ELSE 'Premium ($100+)'
END AS price_tier,
COUNT(*) AS product_count,
ROUND(AVG(price), 2) AS avg_price
FROM products
WHERE is_active = 1
GROUP BY price_tier
ORDER BY avg_price;Pivot Table with Conditional Aggregation
You can simulate a pivot table in SQL using SUM(CASE WHEN ... END) — this is called conditional aggregation. Each CASE creates a "column" for a specific condition.
-- Pivot: monthly revenue broken down by product category SELECT DATE_FORMAT(o.created_at, '%Y-%m') AS month, ROUND(SUM(CASE WHEN c.name = 'Electronics' THEN oi.qty * oi.unit_price END), 2) AS electronics, ROUND(SUM(CASE WHEN c.name = 'Clothing' THEN oi.qty * oi.unit_price END), 2) AS clothing, ROUND(SUM(CASE WHEN c.name = 'Books' THEN oi.qty * oi.unit_price END), 2) AS books, ROUND(SUM(oi.qty * oi.unit_price), 2) AS total FROM orders AS o JOIN order_items AS oi ON o.order_id = oi.order_id JOIN products AS p ON oi.product_id = p.product_id JOIN categories AS c ON p.category_id = c.category_id WHERE o.status = 'delivered' GROUP BY DATE_FORMAT(o.created_at, '%Y-%m') ORDER BY month; -- Count orders by status using conditional aggregation SELECT DATE(created_at) AS date, COUNT(*) AS total, SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending, SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) AS delivered, SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled FROM orders WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY DATE(created_at) ORDER BY date;
GROUP BY with ROLLUP — Subtotals and Grand Total
WITH ROLLUP adds extra summary rows at each grouping level, producing subtotals and a grand total automatically. The summary rows use NULL for the grouped column values.
-- Sales by category and product — with category subtotals + grand total SELECT COALESCE(cat.name, 'ALL CATEGORIES') AS category, COALESCE(p.name, 'SUBTOTAL') AS product, COUNT(DISTINCT o.order_id) AS orders, ROUND(SUM(oi.qty * oi.unit_price), 2) AS revenue FROM orders AS o JOIN order_items AS oi ON o.order_id = oi.order_id JOIN products AS p ON oi.product_id = p.product_id JOIN categories AS cat ON p.category_id = cat.category_id WHERE o.status = 'delivered' GROUP BY cat.name, p.name WITH ROLLUP ORDER BY cat.name, p.name; -- GROUPING() function (MySQL 8.0+): returns 1 for ROLLUP-generated NULL rows -- Distinguishes "real NULL" from "ROLLUP summary NULL" SELECT CASE GROUPING(cat.name) WHEN 1 THEN 'GRAND TOTAL' ELSE cat.name END AS category, CASE GROUPING(p.name) WHEN 1 THEN 'SUBTOTAL' ELSE p.name END AS product, ROUND(SUM(oi.qty * oi.unit_price), 2) AS revenue FROM orders AS o JOIN order_items AS oi ON o.order_id = oi.order_id JOIN products AS p ON oi.product_id = p.product_id JOIN categories AS cat ON p.category_id = cat.category_id WHERE o.status = 'delivered' GROUP BY cat.name, p.name WITH ROLLUP;
COALESCE(col, 'label') on older MySQL versions, or GROUPING() on MySQL 8.0+ to replace ROLLUP-generated NULLs with readable labels like "SUBTOTAL" or "GRAND TOTAL".GROUP BY Order Is Not Guaranteed
A common misconception: GROUP BY does not sort the output. In older MySQL versions it happened to sort, but this was a side effect of the grouping implementation, not a guarantee. Always add an explicit ORDER BY.
-- DO NOT rely on GROUP BY for ordering SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status; -- Order of rows is NOT guaranteed -- Always add explicit ORDER BY SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status ORDER BY cnt DESC;
GROUP BY Performance — Index vs Filesort
GROUP BY can be resolved with an index (fast) or a filesort/hash (slow). MySQL uses an index when the GROUP BY columns match the leading prefix of an index and are in the same order.
-- Index that supports GROUP BY + WHERE + ORDER BY ALTER TABLE orders ADD INDEX idx_status_created (status, created_at); -- Check if the query uses the index EXPLAIN SELECT status, COUNT(*), SUM(total) FROM orders WHERE created_at >= '2024-01-01' GROUP BY status; -- Look for: 'Using index' or 'Using index condition' in Extra column -- Avoid: 'Using filesort' or 'Using temporary' on large tables -- Composite index for multi-column GROUP BY ALTER TABLE orders ADD INDEX idx_customer_status (customer_id, status); EXPLAIN SELECT customer_id, status, COUNT(*) FROM orders GROUP BY customer_id, status; -- Extra: 'Using index' means the group can be read directly from the index
Comprehensive Sales Report Example
-- Weekly sales report: revenue, orders, new vs returning customer count
SELECT
YEARWEEK(o.created_at, 1) AS week,
MIN(DATE(o.created_at)) AS week_start,
COUNT(DISTINCT o.order_id) AS total_orders,
COUNT(DISTINCT o.customer_id) AS active_customers,
ROUND(SUM(o.total), 2) AS gross_revenue,
ROUND(AVG(o.total), 2) AS avg_order_value,
-- Customers whose account is newer than 7 days before this week = new customers
COUNT(DISTINCT CASE
WHEN c.created_at >= DATE_SUB(MIN(o.created_at), INTERVAL 7 DAY)
THEN o.customer_id END) AS new_customers,
-- Returning customers = active - new
COUNT(DISTINCT o.customer_id) -
COUNT(DISTINCT CASE
WHEN c.created_at >= DATE_SUB(MIN(o.created_at), INTERVAL 7 DAY)
THEN o.customer_id END) AS returning_customers
FROM orders AS o
JOIN customers AS c ON o.customer_id = c.customer_id
WHERE o.status = 'delivered'
AND o.created_at >= DATE_SUB(CURDATE(), INTERVAL 12 WEEK)
GROUP BY YEARWEEK(o.created_at, 1)
ORDER BY week;GROUP BY Performance Tips
Add an index on the GROUP BY column(s) — MySQL can use it to avoid a filesort
Filter with WHERE before GROUP BY to reduce the number of rows being grouped
Use HAVING to filter groups after aggregation, never WHERE for aggregate conditions
SELECT only the columns you need — fewer columns means less data to sort and group
With large result sets, compute GROUP BY in a subquery then join back to details
EXPLAIN your GROUP BY queries — watch for "Using filesort" or "Using temporary"
ONLY_FULL_GROUP_BY is on by default — fix queries rather than disabling the mode
Clause | Runs when | Can use aggregates? | Can use index? |
|---|---|---|---|
WHERE | Before grouping | No | Yes |
GROUP BY | Groups remaining rows | N/A | Yes (avoids filesort) |
HAVING | After grouping | Yes | No |
ORDER BY | After HAVING | Yes | Yes (if matches GROUP BY index) |
LIMIT | Last | N/A | N/A |
HAVING vs WHERE — Common Mistakes
-- WRONG: cannot use aggregate in WHERE clause SELECT customer_id, SUM(total) AS revenue FROM orders WHERE SUM(total) > 500 -- ERROR: Invalid use of group function GROUP BY customer_id; -- CORRECT: filter on aggregated values with HAVING SELECT customer_id, SUM(total) AS revenue FROM orders GROUP BY customer_id HAVING SUM(total) > 500; -- HAVING with alias (MySQL allows this as an extension) SELECT customer_id, SUM(total) AS revenue FROM orders GROUP BY customer_id HAVING revenue > 500; -- MySQL accepts column alias in HAVING -- Filter on individual columns in WHERE, group conditions in HAVING SELECT customer_id, COUNT(*) AS order_count, SUM(total) AS revenue FROM orders WHERE status = 'delivered' -- WHERE: filter rows before grouping (faster, uses index) GROUP BY customer_id HAVING order_count >= 3 -- HAVING: filter groups after aggregation AND revenue > 100;
GROUP BY with Window Functions
Combining GROUP BY aggregation with window functions (MySQL 8.0+) lets you compute both per-group totals and running totals in a single query:
-- Monthly revenue with running total using a CTE + window function
WITH monthly AS (
SELECT
DATE_FORMAT(created_at, '%Y-%m') AS month,
ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE status = 'delivered'
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
)
SELECT
month,
revenue,
SUM(revenue) OVER (ORDER BY month ROWS UNBOUNDED PRECEDING) AS running_total,
ROUND(revenue / SUM(revenue) OVER () * 100, 1) AS pct_of_total
FROM monthly
ORDER BY month;GROUP BY Quick Reference
Topic | Rule / Command |
|---|---|
Basic syntax | SELECT col, AGG(col2) FROM t GROUP BY col |
Multiple columns | GROUP BY col1, col2 — one group per unique combination |
Expressions | GROUP BY DATE_FORMAT(created_at, "%Y-%m") — any expression is valid |
ONLY_FULL_GROUP_BY | Every non-aggregate SELECT column must be in GROUP BY or use ANY_VALUE() |
HAVING syntax | SELECT col, COUNT() FROM t GROUP BY col HAVING COUNT() > 5 |
ROLLUP subtotals | GROUP BY col1, col2 WITH ROLLUP — adds subtotal and grand total rows |
GROUPING() | Returns 1 for ROLLUP-generated NULL rows (MySQL 8.0+) |
Index usage | Index on GROUP BY columns avoids filesort — check EXPLAIN |
Pivot pattern | SUM(CASE WHEN status = "active" THEN 1 ELSE 0 END) AS active_count |
Order not guaranteed | Always add ORDER BY — GROUP BY does not sort the result |
Aggregate Functions Summary
Function | Description | NULL handling |
|---|---|---|
COUNT(*) | Count all rows | Includes rows with NULL values |
COUNT(col) | Count non-NULL values | Skips NULL values |
SUM(col) | Total of values | Skips NULLs; returns NULL if no rows |
AVG(col) | Average of non-NULL values | Skips NULLs; returns NULL if no rows |
MIN(col) / MAX(col) | Minimum / maximum value | Skips NULLs |
GROUP_CONCAT(col) | Values as comma-separated string | Skips NULLs |
GROUP_CONCAT — Aggregating Multiple Values as a String
GROUP_CONCAT() is a powerful MySQL-specific aggregate function that combines values from multiple rows into a single comma-separated string per group — very useful for building tag lists, CSV reports, or debugging.
-- List all product names per category as a CSV string SELECT c.name AS category, GROUP_CONCAT(p.name ORDER BY p.name SEPARATOR ', ') AS products FROM categories c JOIN products p ON c.id = p.category_id WHERE p.is_active = 1 GROUP BY c.id, c.name; -- category: Electronics -- products: Laptop, Mouse, Phone, Tablet -- Count + list — useful for debugging duplicate detection SELECT email, COUNT(*) AS cnt, GROUP_CONCAT(id ORDER BY id) AS duplicate_ids FROM users GROUP BY email HAVING cnt > 1; -- Increase max length (default is 1024 bytes) SET SESSION group_concat_max_len = 65536;