MySQLGROUP BY

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

SQL
-- 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
Note
Use 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

SQL
-- 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.

SQL
-- 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;
Warning
Disabling 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

SQL
-- 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.

SQL
-- 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.

SQL
-- 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.

SQL
-- 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;
Tip
Use 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.

SQL
-- 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.

SQL
-- 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

SQL
-- 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

SQL
-- 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:

SQL
-- 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.

SQL
-- 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;