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.

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.

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.

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?

Ask what the condition is about.

About one row? It goes in WHERE. amount > 200, region = 'North', sold_at >= '2026-01-01' — each of these can be answered by looking at a single row, so put it in WHERE and let the database discard those rows as early and as cheaply as possible.

About a whole group? It goes in HAVING. HAVING COUNT(*) > 3, HAVING SUM(amount) > 1000 — these cannot be answered from one row at all.

A condition on the grouping column itself is legal in either place and belongs in WHERE, because filtering before you group is strictly less work than grouping and then throwing groups away.

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.

Interactive Exploration Guide

  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.

Key Takeaway

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.

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