GROUP BY vs HAVING in SQL: What's the Difference?
6 min read · Updated August 13, 2026
This one trips up almost everyone learning SQL, and it comes down to a single idea: WHERE filters rows before grouping. HAVING filters groups after grouping. Once that clicks, the rest is just syntax.
What GROUP BY actually does
GROUP BY collapses many rows into one row per unique value (or combination of values) in the columns you name. It's almost always paired with an aggregate function — COUNT, SUM, AVG, MIN, MAX — that summarizes each group.
SELECT dept_id, COUNT(*) AS headcount, AVG(salary) AS avg_salary
FROM Employees
GROUP BY dept_id;
Instead of one row per employee, this returns one row per department, with headcount and average salary computed across all employees in that group. Every column in the SELECT list must either be in the GROUP BY clause or wrapped in an aggregate function — the database wouldn't know which employee's raw salary to show for a group otherwise.
Why WHERE can't filter on COUNT() or SUM()
SQL evaluates clauses in roughly this order: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. WHERE runs before grouping happens, so at that point there's no such thing as "the department's headcount" yet — each row is still an individual employee. This is why the following is invalid:
-- Invalid: COUNT(*) doesn't exist yet at the WHERE stage
SELECT dept_id, COUNT(*) AS headcount
FROM Employees
WHERE COUNT(*) > 2
GROUP BY dept_id;
HAVING: WHERE, but for groups
HAVING runs after GROUP BY, once the aggregates have been calculated — so it can filter on them directly:
SELECT dept_id, COUNT(*) AS headcount
FROM Employees
GROUP BY dept_id
HAVING COUNT(*) > 2;
This returns only departments with more than two employees — the small departments are dropped from the output entirely.
Using both together
WHERE and HAVING aren't mutually exclusive — a real query often uses both, each doing its own job:
SELECT c.company, SUM(o.order_total) AS revenue
FROM Orders o
JOIN Customers c ON c.customer_id = o.customer_id
WHERE o.status <> 'Cancelled' -- drop cancelled orders BEFORE summing
GROUP BY c.company
HAVING SUM(o.order_total) > 5000 -- keep only high-revenue customers
ORDER BY revenue DESC;
Here, WHERE excludes cancelled orders row-by-row before anything is totaled, then GROUP BY totals revenue per company, and finally HAVING keeps only the companies whose total exceeds 5,000.
Quick reference
| WHERE | HAVING | |
|---|---|---|
| Filters | Individual rows | Groups (after aggregation) |
| Runs | Before GROUP BY | After GROUP BY |
| Can use aggregates? | No | Yes |
| Works without GROUP BY? | Yes | Yes (treats the whole table as one group) |
Try it yourself
Run the Orders/Customers example above directly — both tables are already loaded in the Playground.
Open the Playground