Coding tips

SQL WHERE vs HAVING: Differences, Execution Order and When to Use Each (Tested Examples)

WHERE filters individual rows before GROUP BY, while HAVING filters the groups after it and can use aggregates like SUM() and COUNT(). Here is how they differ, with real results, errors and query plans from MySQL 8.4, PostgreSQL 17 and SQLite 3.46.

By 13 min read 0 views
Cover image for SQL WHERE vs HAVING: Differences, Execution Order and When to Use Each (Tested Examples)

WHERE filters individual rows before they are grouped, and HAVING filters whole groups after GROUP BY has run. That's why only HAVING can use aggregate functions such as SUM(), COUNT() and AVG(). Use WHERE for conditions on plain columns, like status = 'paid', and HAVING for conditions on totals, like SUM(amount) > 100. You can use both in one query, and WHERE always runs first.

The PostgreSQL SELECT reference puts it in one line: WHERE filters individual rows before GROUP BY is applied, while HAVING filters group rows created by GROUP BY. The rest of this guide shows what that means in practice, including the errors you get when you mix them up and a case where the wrong choice makes MySQL read the whole table.

I ran every query below on MySQL 8.4.8 (default ONLY_FULL_GROUP_BY mode), PostgreSQL 17.11 and SQLite 3.46.1. All the results and error messages are copied from those runs.

In this guide:

  • WHERE vs HAVING at a glance
  • The sample table I used
  • Where each clause runs in a query
  • WHERE: filter individual rows
  • HAVING: filter groups using aggregates
  • Why WHERE SUM(...) fails: the errors in MySQL, PostgreSQL and SQLite
  • Using WHERE and HAVING together
  • The trap: WHERE removes groups you wanted to keep
  • Non-aggregate conditions: same result, different cost
  • HAVING on a column that is not grouped
  • Can you use a column alias in WHERE or HAVING?
  • HAVING without GROUP BY
  • Quick rules for choosing
  • Frequently asked questions

WHERE vs HAVING at a glance

WHEREHAVING
FiltersIndividual rowsGroups of rows
RunsBefore GROUP BYAfter GROUP BY and aggregation
Aggregates like SUM()Not allowed (error)Allowed, and the main reason to use it
Needs GROUP BY?NoUsually used with it; without it the whole result is one group
Works in UPDATE and DELETEYesNo, SELECT only
Can use indexesYesNot reliably (see the performance test below)
SELECT aliasesNot allowed in MySQL or PostgreSQLAllowed in MySQL and SQLite, not in PostgreSQL

If you remember one rule, make it this: if the condition mentions an aggregate, it goes in HAVING. Otherwise, it goes in WHERE.

The sample table I used

Every example uses the same ten orders from four customers. It's small enough to check the answers by hand:

CREATE TABLE orders (
  id       INT PRIMARY KEY,
  customer VARCHAR(20) NOT NULL,
  city     VARCHAR(20) NOT NULL,
  status   VARCHAR(10) NOT NULL,
  amount   DECIMAL(8,2) NOT NULL
);
INSERT INTO orders VALUES
 (1, 'Ana',  'London', 'paid',     60.00),
 (2, 'Ana',  'London', 'paid',     55.00),
 (3, 'Ana',  'London', 'refunded', 40.00),
 (4, 'Ben',  'Leeds',  'paid',    120.00),
 (5, 'Ben',  'Leeds',  'pending',  30.00),
 (6, 'Cara', 'London', 'pending',  25.00),
 (7, 'Cara', 'London', 'pending',  15.00),
 (8, 'Dev',  'Leeds',  'paid',     20.00),
 (9, 'Dev',  'Leeds',  'paid',     35.00),
 (10,'Dev',  'Leeds',  'paid',     10.00);

I show MySQL's output in most places. PostgreSQL and SQLite returned the same rows unless I say otherwise. SQLite prints 155 where the others print 155.00, because it doesn't keep DECIMAL scale. If joins are still fuzzy for you, my guide to INNER vs LEFT vs RIGHT vs FULL OUTER JOIN uses a similar shop table.

Where each clause runs in a query

You write SQL in one order, but the database evaluates it in another. This logical order explains almost every WHERE vs HAVING question:

  1. FROM and JOINs pick the source rows.
  2. WHERE throws away rows that fail the condition.
  3. GROUP BY collects the surviving rows into groups.
  4. Aggregates such as SUM() and COUNT() are calculated per group.
  5. HAVING throws away groups that fail the condition.
  6. SELECT computes the output columns and aliases.
  7. ORDER BY sorts, then LIMIT trims.

Because WHERE runs before step 4, no totals exist yet when it runs, so it can't test them. HAVING runs after step 4, so it can. The PostgreSQL tutorial on aggregate functions walks through the same idea. Real query planners can reorder work for speed, but they must return the same result as this logical order.

WHERE: filter individual rows

WHERE checks each row on its own. Here it keeps paid orders worth at least 50:

SELECT id, customer, status, amount
FROM orders
WHERE status = 'paid' AND amount >= 50
ORDER BY id;
+----+----------+--------+--------+
| id | customer | status | amount |
+----+----------+--------+--------+
|  1 | Ana      | paid   |  60.00 |
|  2 | Ana      | paid   |  55.00 |
|  4 | Ben      | paid   | 120.00 |
+----+----------+--------+--------+

No grouping is involved. Each of the ten rows passes or fails by itself, and three pass. WHERE is also the clause you use in UPDATE and DELETE, where HAVING isn't allowed at all.

HAVING: filter groups using aggregates

Now say you want customers who have spent more than 100 in total. That condition is about a group of rows, so it belongs in HAVING:

SELECT customer, COUNT(*) AS orders, SUM(amount) AS total
FROM orders
GROUP BY customer
HAVING SUM(amount) > 100
ORDER BY customer;
+----------+--------+--------+
| customer | orders | total  |
+----------+--------+--------+
| Ana      |      3 | 155.00 |
| Ben      |      2 | 150.00 |
+----------+--------+--------+

GROUP BY made four groups (Ana, Ben, Cara and Dev). HAVING kept the two whose total is over 100. Cara (40.00) and Dev (65.00) were removed as whole groups.

HAVING can combine several aggregate conditions, and they don't have to appear in the SELECT list:

SELECT customer, COUNT(DISTINCT status) AS statuses, MAX(amount) AS biggest
FROM orders
GROUP BY customer
HAVING COUNT(DISTINCT status) > 1 AND MAX(amount) >= 50
ORDER BY customer;
| Ana      |        2 |   60.00 |
| Ben      |        2 |  120.00 |

The most common real use of HAVING COUNT(*) > 1 is finding duplicates. I use it that way in how to delete duplicate rows in MySQL but keep one.

Why WHERE SUM(...) fails: the errors in MySQL, PostgreSQL and SQLite

Put an aggregate in WHERE and all three databases refuse to run the query. This is the exact query:

SELECT customer, SUM(amount) AS total
FROM orders
WHERE SUM(amount) > 100
GROUP BY customer;

And these are the three errors:

ERROR 1111 (HY000) at line 1: Invalid use of group function
ERROR:  aggregate functions are not allowed in WHERE
LINE 3: WHERE SUM(amount) > 100
Parse error near line 1: misuse of aggregate: SUM()

That's MySQL, PostgreSQL and SQLite in order. MySQL's "Invalid use of group function" is the least obvious of the three, but it means the same thing. Fix: move the aggregate condition into HAVING, as in the previous section. The MySQL SELECT documentation says plainly that WHERE can use any function except aggregate (group) functions.

Using WHERE and HAVING together

The two clauses work well as a team. WHERE trims the rows first, then HAVING judges the groups that are left. This query asks: which customers have at least two paid orders?

SELECT customer, COUNT(*) AS paid_orders, SUM(amount) AS paid_total
FROM orders
WHERE status = 'paid'
GROUP BY customer
HAVING COUNT(*) >= 2
ORDER BY customer;
+----------+-------------+------------+
| customer | paid_orders | paid_total |
+----------+-------------+------------+
| Ana      |           2 |     115.00 |
| Dev      |           3 |      65.00 |
+----------+-------------+------------+

Compare that with the same HAVING but no WHERE. Now every customer qualifies, because everyone has at least two orders of some kind:

| Ana      |          3 |    155.00 |
| Ben      |          2 |    150.00 |
| Cara     |          2 |     40.00 |
| Dev      |          3 |     65.00 |

Ben drops out of the first query because WHERE removed his pending order before counting, which left him one paid order. Ana's total falls from 155.00 to 115.00 because her refunded order was removed. Where you put a condition changes the numbers, not just which rows appear.

The trap: WHERE removes groups you wanted to keep

Here's a mistake I see in dashboards all the time. You want the number of paid orders for every customer, so you filter with WHERE:

SELECT customer, COUNT(*) AS paid_orders
FROM orders
WHERE status = 'paid'
GROUP BY customer
ORDER BY customer;
| Ana      |           2 |
| Ben      |           1 |
| Dev      |           3 |

Cara has vanished. She has no paid orders, so WHERE removed all her rows and there was nothing left to group. You wanted to see "Cara: 0". Fix: keep all rows and count conditionally inside the aggregate:

SELECT customer,
       SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_orders,
       COUNT(*) AS all_orders
FROM orders
GROUP BY customer
ORDER BY customer;
| Ana      |           2 |          3 |
| Ben      |           1 |          2 |
| Cara     |           0 |          2 |
| Dev      |           3 |          3 |

PostgreSQL and SQLite also support the standard FILTER clause, COUNT(*) FILTER (WHERE status = 'paid'), which gave the same four rows. MySQL 8.4.8 doesn't support it and returned ERROR 1064 (42000), a syntax error, so use the CASE version if your code has to run on MySQL. SQLite documents FILTER on its aggregate functions page.

Non-aggregate conditions: same result, different cost

You can put a plain column condition in HAVING, as long as that column is in the GROUP BY. These two queries returned the identical result in all three databases:

SELECT city, COUNT(*) AS orders
FROM orders
GROUP BY city
HAVING city = 'London';
SELECT city, COUNT(*) AS orders
FROM orders
WHERE city = 'London'
GROUP BY city;
| London |      5 |

So does it matter which one you use? To find out, I loaded a 100,000-row table with 1,000 customers and an index on customer_id, then compared the query plans for WHERE customer_id = 42 and HAVING customer_id = 42. Both queries returned a total of 9400. This is MySQL's plan for the WHERE version:

-> Group aggregate: sum(big_orders.amount)  (cost=45 rows=1)
    -> Index lookup on big_orders using idx_big_customer (customer_id=42)  (cost=35 rows=100)

And this is the plan for the HAVING version:

-> Filter: (big_orders.customer_id = 42)  (cost=20076 rows=982)
    -> Group aggregate: sum(big_orders.amount)  (cost=20076 rows=982)
        -> Index scan on big_orders using idx_big_customer  (cost=10066 rows=100098)

With WHERE, MySQL looked up about 100 rows through the index. With HAVING, it scanned the whole index (about 100,000 rows), built every customer's total, then threw away all but one. The estimated cost went from 45 to 20076. The MySQL manual warns about this directly: HAVING is applied nearly last, "with no optimization", and you shouldn't use it for items that belong in WHERE.

PostgreSQL 17 and SQLite were smarter. Both moved the condition down and used the index for either version:

         ->  Bitmap Index Scan on idx_big_customer
               Index Cond: (customer_id = 42)
`--SEARCH big_orders USING INDEX idx_big_customer (customer_id=?)

You can't count on that, though, so the safe habit is the same everywhere: plain column conditions go in WHERE. You can check your own queries with EXPLAIN.

HAVING on a column that is not grouped

What happens if HAVING uses a column that is neither grouped nor aggregated? Here I group by customer but filter on status:

SELECT customer, SUM(amount) AS total
FROM orders
GROUP BY customer
HAVING status = 'paid';
ERROR 1054 (42S22) at line 1: Unknown column 'status' in 'having clause'
ERROR:  column "orders.status" must appear in the GROUP BY clause or be used in an aggregate function

MySQL and PostgreSQL both reject it, which is the right call, because a group like Ana's contains both paid and refunded orders. SQLite, however, ran it and returned three rows:

| Ana      | 155   |
| Ben      | 150   |
| Dev      | 65    |

That's a silent wrong answer. Ana's total of 155 includes a refunded order, and Ben's includes a pending one. The SQLite SELECT docs explain why: a non-aggregate HAVING expression is evaluated against an arbitrarily selected row from each group. If you meant "only paid orders", that condition belongs in WHERE.

Can you use a column alias in WHERE or HAVING?

Aliases are where the three databases disagree most. I tested a SELECT alias in HAVING (HAVING total > 100, where total is SUM(amount)) and in WHERE (WHERE with_vat > 100, where with_vat is amount * 1.2):

DatabaseAlias in HAVINGAlias in WHERE
MySQL 8.4.8WorksError 1054: Unknown column 'with_vat' in 'where clause'
PostgreSQL 17.11Error: column "total" does not existError: column "with_vat" does not exist
SQLite 3.46.1WorksWorks
ERROR:  column "total" does not exist
LINE 4: HAVING total > 100

That's PostgreSQL's documented behaviour: output column names work in ORDER BY and GROUP BY, but not in WHERE or HAVING. The portable fix is to repeat the expression (HAVING SUM(amount) > 100), or to compute it in a CTE and filter it with WHERE outside:

WITH totals AS (
  SELECT customer, SUM(amount) AS total
  FROM orders
  GROUP BY customer
)
SELECT customer, total
FROM totals
WHERE total > 100
ORDER BY customer;
| Ana      | 155.00 |
| Ben      | 150.00 |

That CTE ran unchanged on all three databases. When you build these filters from user input in PHP, pass the values as placeholders, as shown in my PDO prepared statements guide, whether the condition sits in WHERE or HAVING.

HAVING without GROUP BY

HAVING without GROUP BY treats the whole filtered result as one group. With an aggregate, all three databases agree:

SELECT COUNT(*) AS paid_orders
FROM orders
WHERE status = 'paid'
HAVING COUNT(*) > 3;
|           6 |

The PostgreSQL docs note that a query like this returns one row when the HAVING condition is true and zero rows when it isn't. Things split when there's no aggregate at all, as in SELECT id, amount FROM orders HAVING amount > 100. MySQL treats it like a WHERE and returns order 4. The other two refuse:

ERROR:  column "orders.id" must appear in the GROUP BY clause or be used in an aggregate function
Parse error near line 1: HAVING clause on a non-aggregate query

That's PostgreSQL first, then SQLite. Code that relies on MySQL's leniency here will break if you ever move databases.

Quick rules for choosing

  • Condition on a plain column (status, city, a date)? Use WHERE.
  • Condition on SUM(), COUNT(), AVG(), MIN() or MAX()? Use HAVING.
  • Need groups with zero matches to stay visible? Don't filter them out with WHERE. Count conditionally with CASE, or FILTER outside MySQL.
  • Writing portable SQL? Repeat the aggregate in HAVING rather than using its alias, or use a CTE.
  • Not sure? Run EXPLAIN and check that your WHERE condition uses an index.

I keep a set of small browser-based helpers for everyday developer jobs on my free tools page, if you need to tidy test data before importing it.

Frequently asked questions

What is the main difference between WHERE and HAVING in SQL?

WHERE filters individual rows before they are grouped, and HAVING filters groups after GROUP BY and aggregation. That's why HAVING can test SUM(), COUNT() and other aggregates, while WHERE can't.

Can I use HAVING without GROUP BY?

Yes. The whole filtered result then counts as one group, so a query like SELECT COUNT(*) FROM orders HAVING COUNT(*) > 3 returns one row or none. A HAVING with no aggregate at all works in MySQL but fails in PostgreSQL and SQLite.

Why can't I use COUNT() or SUM() in a WHERE clause?

WHERE runs before rows are grouped, so the totals don't exist yet. MySQL reports Invalid use of group function, PostgreSQL reports aggregate functions are not allowed in WHERE, and SQLite reports misuse of aggregate. Move the condition to HAVING.

Is HAVING slower than WHERE?

It can be. In my MySQL 8.4.8 test, a plain column condition in HAVING scanned all 100,000 rows, while the same condition in WHERE read about 100 through an index. PostgreSQL 17 and SQLite optimised both forms the same way, but WHERE is the safe choice everywhere.

Which runs first, WHERE or HAVING?

WHERE runs first. The logical order is FROM, WHERE, GROUP BY, aggregates, HAVING, SELECT, then ORDER BY and LIMIT.

Can I use a column alias in HAVING?

In MySQL and SQLite, yes. In PostgreSQL, no: it reports that the column does not exist. Repeat the expression, for example HAVING SUM(amount) > 100, or wrap the query in a CTE if you need portable SQL.

Can I use both WHERE and HAVING in the same query?

Yes, and it's common. WHERE removes unwanted rows first, then HAVING filters the groups that are left. Keep in mind that the WHERE condition changes what gets counted and summed.

Last checked: 4 November 2026. Figures, settings and guidance can change, so confirm anything important with the sources linked above.

Share X LinkedIn WhatsApp Facebook
Was this helpful?

0%