AcademySQL
GROUP BY and HAVING
The order the clauses actually run in, why WHERE and HAVING are not interchangeable, and what COUNT(*) counts that COUNT(column) does not.
3 min readUpdated 3 exercises
The clauses do not run in the order you write them
SQL is written SELECT ... FROM ... WHERE ... GROUP BY ... HAVING ... ORDER BY, and evaluated in a different order entirely:
FROM → which rows exist
WHERE → throw rows away
GROUP BY → fold the survivors into groups
HAVING → throw groups away
SELECT → compute the output columns
ORDER BY → sort what is leftAlmost every confusing error about grouping comes from that list. WHERE cannot see an aggregate, because no group exists yet when it runs. HAVING can, because it runs after. And an alias defined in SELECT is not usable in WHERE — it does not exist yet either.
WHERE filters rows, HAVING filters groups
SELECT customer_id, SUM(total) AS spent
FROM orders
WHERE status = 'paid' -- rows: only paid orders count
GROUP BY customer_id
HAVING SUM(total) > 1000; -- groups: only big spenders surviveSwap them and the query is either wrong or refuses to run. WHERE SUM(total) > 1000 is an error. HAVING status = 'paid' is worse in some engines — it runs, after the sum has already included the unpaid orders.
The rule: if the condition is about a single row, it is `WHERE`. If it needs the whole group, it is `HAVING`. Put everything you can in WHERE: fewer rows reach the grouping, and the query is faster for the same answer.
Every selected column must be grouped or aggregated
-- Error in a standards-compliant engine
SELECT department_id, name, COUNT(*)
FROM employees
GROUP BY department_id;The group is a department; name is a property of one employee, and the engine has no way to choose which. MySQL historically returned an arbitrary one, which is how this became a habit people carry between databases. If you want a representative value, say which one:
SELECT department_id, MIN(name) AS first_name_alphabetically, COUNT(*)
FROM employees
GROUP BY department_id;COUNT(*) and COUNT(column) are different questions
| Expression | Counts |
|---|---|
COUNT(*) | rows in the group |
COUNT(column) | rows where that column is not NULL |
COUNT(DISTINCT column) | distinct non-NULL values |
SUM(column) | NULL if the group has no rows at all |
That second line is a quiet source of wrong dashboards:
SELECT COUNT(*) AS employees,
COUNT(manager_id) AS employees_with_a_manager
FROM employees;Same table, two different numbers, and the difference is exactly the number of NULLs.
Counting rows that match a condition
The portable trick, worth knowing because it lets one pass produce several numbers:
SELECT
COUNT(*) AS orders,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid,
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) AS refunded
FROM orders;Three counts, one scan of the table. The alternative — three queries, or three subqueries — reads the same rows three times.
A group that does not exist produces no row
SELECT status, COUNT(*)
FROM orders
GROUP BY status;If no order was ever refunded, there is no refunded row in the result — not a row with zero. Grouping can only fold rows that exist. When a report needs every status present, the statuses have to come from somewhere else: a lookup table, joined LEFT, with the orders on the right.
Now practice it
2 challenges · 1 quizReading this page is the cheap half. These are the exercises that make you use it.
- Count Orders by StatusGrouping and counting on one table, which is where the difference between COUNT(*) and COUNT(col) shows up.ChallengeSolve · EasyChallengeSolve · Easy
- Top Spending CustomersA filter that can only be expressed after the groups exist — the case HAVING is for.ChallengeSolve · MediumChallengeSolve · Medium
- Fundamentals · Joins & AggregationsJoins and aggregation questions, where the evaluation order is usually what decides the right answer.QuizTake · MediumQuizTake · Medium
Read next
JOINs, and the rows you did not expect
What a join really computes, the difference INNER and LEFT actually make, and why filtering a LEFT JOIN in the WHERE clause silently turns it back into an INNER one.
3 min
Window functions: keeping every row
How OVER keeps your rows while still computing across them, what PARTITION BY and ORDER BY each change, and the frame clause that decides a running total.
3 min