Modules / Database / HAVING Lab

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.

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:

ConditionGoes in
On a raw column value (status = 'paid')WHERE
On a column you are grouping byWHERE — cheaper, same result
On an aggregate (COUNT(*) > 5)HAVING
On a SELECT alias for an aggregateHAVING, and in most engines you must repeat the expression
On a window functionNeither — 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 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.

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:

RegionDateAmount
North2025-11-028,000
North2026-02-144,000
South2026-01-207,000
South2026-03-015,000
East2025-12-109,000
East2026-04-022,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
Result

Experiments to try

  1. Start with HAVING alone. WHERE is at 0, so all 14 rows are grouped into 4 regions and HAVING SUM(amount) ≥ 800 removes one of them. Note which, and note its total.
  2. 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.
  3. Read the strike-throughs. Each group still lists the amounts WHERE removed, struck out. Those are the values missing from the totals above them.
  4. 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.
  5. 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.
  6. 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.

  1. Without scrolling back — what is the one-line takeaway from this module?

  2. What does this module say about “Quick Context”?

  3. What does this module say about “Two things people are surprised by”?

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.

DATABASE · vizlearn.in/database/having_in_sql.html

About the author

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.