WHERE throws away rows before they are grouped. HAVING throws away groups after they are formed. Run both at once on the same table and watch the two stages do genuinely different work.
Overview
Quick Context
A grouped query runs in stages. Rows are read, WHERE discards the ones that fail a per-row test, what is left is collected into groups, each group is boiled down to its aggregates, and only then does HAVING get a look — at the groups, not the rows.
That ordering is the entire module. WHERE cannot see SUM() because no sum exists yet when WHERE runs. HAVING can, because by the time it runs the individual rows are gone.
1 Rows — WHERE decides which ones even reach the grouping
2 Groups — aggregates computed from the surviving rows only
A struck-through amount is a row WHERE removed, so it never contributed to the total above it. That is the whole reason moving a condition between the two clauses changes the numbers rather than just the row count.
3 Result — the groups HAVING let through
HAVING: A Filter That Runs After the Grouping
Same word, "filter". Different stage, different input, different answer.
The order that explains everything
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
Three consequences fall straight out of it:
WHERE changes what the aggregates are. Filtering rows out before grouping removes their contribution from every SUM, AVG and COUNT downstream. Watch the totals in stage 2 move as you raise the WHERE threshold — the group is still there, but it is now the total of fewer rows.
HAVING cannot bring anything back. It only ever removes whole groups from a set that has already been computed.
Neither can see a SELECT alias in standard SQL, because SELECT runs after both. MySQL and SQLite allow an alias in HAVING as an extension; PostgreSQL and SQL Server do not.
Which clause does a condition belong in?
A short decision rule that covers nearly everything:
Condition
Goes in
On a raw column value (status = 'paid')
WHERE
On a column you are grouping by
WHERE — cheaper, same result
On an aggregate (COUNT(*) > 5)
HAVING
On a SELECT alias for an aggregate
HAVING, and in most engines you must repeat the expression
On a window function
Neither — wrap in a CTE and use WHERE outside
That last row catches people regularly. Window functions are computed after HAVING, so filtering on ROW_NUMBER() requires another query level.
Note also that HAVING without GROUP BY is legal: the whole result set is treated as one group. SELECT SUM(amount) FROM sales HAVING SUM(amount) > 100000 returns either one row or none, which is occasionally a neat way to write a threshold check.
Two things people are surprised by
HAVING works without GROUP BY. With no GROUP BY the whole table is one implicit group, so SELECTSUM(amount) FROM sales HAVINGSUM(amount) > 5000 returns either one row or none.An aggregate in HAVING need not appear in SELECT. You can filter on COUNT(*) while selecting only the region. The aggregate is computed either way; SELECT just decides what is shown.
Two filters, two moments
WHERE and HAVING both remove rows. The difference is when they run, and everything else follows from that.
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
WHERE runs before the grouping, so it sees individual rows and knows nothing about totals. HAVING runs after, so it sees one row per group and can test aggregates.
SELECT region, SUM(amount) AS total
FROM sales
WHERE sale_date >= '2026-01-01' -- individual rows: recent sales only
GROUP BY region
HAVING SUM(amount) > 10000 -- whole groups: big regions only
ORDER BY total DESC;
Swap those two conditions and both fail. WHERE SUM(amount) > 10000 is an error, because no sum exists yet when WHERE runs. HAVING sale_date >= '2026-01-01' is either an error or, worse, silently meaningless — the group has many dates and no single one to test.
A worked example where the order changes the answer
Six sales, three regions:
Region
Date
Amount
North
2025-11-02
8,000
North
2026-02-14
4,000
South
2026-01-20
7,000
South
2026-03-01
5,000
East
2025-12-10
9,000
East
2026-04-02
2,000
Now run two queries that look similar and are not.
Filter first (WHERE), then group: only 2026 sales survive, so the totals are North 4,000, South 12,000, East 2,000. With HAVING SUM(amount) > 10000, only South is returned.
Group first, then filter groups (HAVING alone): totals are North 12,000, South 12,000, East 11,000 — all three exceed 10,000 and all three are returned.
Same data, same threshold, entirely different report. When someone says "these numbers don't match the other dashboard", this is frequently why.
The performance implication points the same way: WHERE throws rows away before the expensive grouping happens, so any condition that can go in WHERE should.
WHERE and HAVING on the same query
WHERE filters rows before they are grouped and HAVING filters groups after. Running both on one query is the quickest way to see that they are not interchangeable, and to see the error you get when you reach for the wrong one.
query.sqlSQLite
-- WHERE runs first, on individual rows.
-- HAVING runs last, on the groups WHERE left behind.
--
-- Move the HAVING condition into the WHERE clause and re-run. SQLite
-- answers "misuse of aggregate: count()" -- at the moment WHERE is
-- evaluated the groups do not exist yet, so there is nothing to count.
SELECT c.name AS customer,
count(o.id) AS shipped_orders
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'shipped' -- drops rows, before grouping
GROUP BY c.id, c.name
HAVING count(o.id) > 1 -- drops groups, after
ORDER BY shipped_orders DESC;
Result
Experiments to try
Start with HAVING alone. WHERE is at 0, so all 14 rows are grouped into 4 regions and HAVINGSUM(amount) ≥ 800 removes one of them. Note which, and note its total.
Now add a row filter. Push WHERE amount ≥ to 200. Seven of the fourteen rows vanish from stage 1, every total in stage 2 drops, and every group that passed HAVING a moment ago now fails it — without HAVING itself changing at all.
Read the strike-throughs. Each group still lists the amounts WHERE removed, struck out. Those are the values missing from the totals above them.
Filter on a count instead. Set HAVING Aggregate to COUNT(*); the threshold slider retunes to a count-sized range and lands on 3. With WHERE at 0 every region qualifies, because the smallest has three sales. Push WHERE to 200 and none do.
Group by something finer. Put WHERE back to 0, set the aggregate to SUM(amount) with a threshold of 800, then set GROUP BY to rep. Nine small groups instead of four large ones, and not one reaches 800 — the biggest is 730. A HAVING threshold is only meaningful for the grouping it was chosen for.
Make HAVING empty-handed. Push the threshold past every group's total. Zero rows returned — which is a perfectly ordinary result, not an error.
In one line
WHERE filters rows before grouping and HAVING filters groups after, which is why WHERE cannot mention an aggregate and why HAVING is the only place a condition on SUM or COUNT can live. The consequence people miss is that WHERE does not merely remove rows from the output — it changes what every aggregate downstream is computed from, so moving one condition between the two clauses changes the numbers, not just the row count. Put per-row conditions in WHERE, group conditions in HAVING, and when a condition is legal in both, put it in WHERE: filtering early is always the cheaper plan.
Patterns worth knowing
Find duplicates. The canonical use of HAVING, and worth memorising:
SELECT email, COUNT(*) AS copies
FROM users
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY copies DESC;
Find customers meeting a volume threshold:
SELECT customer_id, COUNT(*) AS orders, SUM(total) AS spend
FROM orders
WHERE order_date >= '2026-01-01'
GROUP BY customer_id
HAVING COUNT(*) >= 3 AND SUM(total) > 500;
Several conditions combine with AND/OR exactly as in WHERE.
Compare a group against the overall average, using a scalar subquery inside HAVING:
SELECT department, AVG(salary) AS dept_avg
FROM employees
GROUP BY department
HAVING AVG(salary) > (SELECT AVG(salary) FROM employees);
Exclude groups containing a value, which is a neat trick using a conditional count:
SELECT order_id
FROM order_lines
GROUP BY order_id
HAVING COUNT(*) FILTER (WHERE status = 'cancelled') = 0; -- PostgreSQL
-- portable form: HAVING SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) = 0
Questions people ask
Can HAVING reference a SELECT alias? In MySQL and PostgreSQL, often yes; in standard SQL, no, because SELECT is evaluated after HAVING. Repeating the expression is the portable choice.
Is HAVING slower than WHERE? The clause itself is cheap; what costs is that HAVING operates after grouping, so more rows had to be grouped. Move conditions to WHERE whenever they can go there.
Can I use HAVING without an aggregate? Syntactically yes, on a grouped column, and it will work — but it is the same filter done later and more expensively, so put it in WHERE.
Why does my HAVING COUNT(*) > 1 return nothing? Usually because the grouping is finer than intended — an extra column in GROUP BY makes every group unique. Check the grouping columns first.
Can I filter on a group's minimum or maximum date? Yes, that is exactly what HAVING is for: HAVING MAX(order_date) < '2026-01-01' finds customers who have not ordered this year.
Does HAVING work with DISTINCT?HAVING COUNT(DISTINCT product_id) > 3 is valid and common — "orders containing more than three different products".
Recap in one screen
WHERE filters rows before grouping; HAVING filters groups after it.
A condition on an aggregate can only live in HAVING; a condition on a raw column should live in WHERE.
Which one you use changes the answer, not just the speed — filtering before grouping changes what is summed.
HAVING COUNT(*) > 1 is the standard duplicate finder.
Window functions are computed later still, so filtering on them needs another query level.
Predict, then reveal
About to run: Now add a row filter. Before it does — what happens to the readout?
Committing to an answer first is the point — the reveal runs the experiment on the visualisation above and reads the real value back, so nothing here is scripted.
Recall check
0 of 3
Say the answer out loud before you reveal it — recalling it is what makes it stick, and rereading it is not.
Without scrolling back — what is the one-line takeaway from this module?
WHERE filters rows before grouping and HAVING filters groups after, which is why WHERE cannot mention an aggregate and why HAVING is the only place a condition on SUM or COUNT can live. The consequence people miss is that WHERE does not merely remove rows from the output — it changes what every aggregate downstream is computed from, so moving one condition between the two clauses changes the numbers, not just the row...
What does this module say about “Quick Context”?
A grouped query runs in stages. Rows are read, WHERE discards the ones that fail a per-row test, what is left is collected into groups, each group is boiled down to its aggregates, and only then does HAVING get a look — at the groups, not the rows.
What does this module say about “Two things people are surprised by”?
HAVING works without GROUP BY. With no GROUP BY the whole table is one implicit group, so SELECT SUM (amount) FROM sales HAVING SUM (amount) > 5000 returns either one row or none. An aggregate in HAVING need not appear in SELECT. You can filter on COUNT (*) while selecting only the region. The aggregate is computed either way; SELECT just decides what is shown.
Cheat sheet
HAVING in SQL
WHERE throws away rows before they are grouped. HAVING throws away groups after they are formed. Run both at once on the same table and watch the two stages do genuinely different work.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.