Aggregation and grouping
HAVING, FILTER and conditional aggregation
The idea in 30 seconds
WHERE filters rows before grouping; HAVING filters groups after. To count or sum only some of the rows in each group, add FILTER (WHERE …) to the aggregate. With FILTER you can turn one pass over a table into a whole summary: totals, subtotals and breakdowns side by side.
Helps to know first: Joins and relational querying
On this page
HAVING filters groups
Customers with at least two orders:
SELECT customer_id, count(*) AS orders
FROM orders
GROUP BY customer_id
HAVING count(*) >= 2
ORDER BY customer_id;
customer_id | orders
-------------+--------
1 | 3
2 | 2
3 | 2
4 | 2
6 | 2
The condition is about the group, so it goes in HAVING. Putting it in WHERE is an error:
SELECT customer_id, count(*) FROM orders WHERE count(*) >= 2 GROUP BY customer_id;
ERROR: aggregate functions are not allowed in WHERE
WHERE runs before the groups exist, so there is no count to compare yet.
WHERE | HAVING | |
|---|---|---|
| Runs | Before grouping | After grouping |
| Filters | Rows | Groups |
| Can use aggregates | No | Yes |
Use both together when you need both: WHERE status <> 'cancelled' decides which orders are counted, and HAVING count(*) >= 2 decides which customers are shown.
FILTER: aggregate only some rows
Often you want several counts from the same group: all orders, shipped orders, open orders. Add FILTER (WHERE …) to an aggregate and it only sees the rows that pass:
SELECT customer_id,
count(*) AS orders,
count(*) FILTER (WHERE status = 'shipped') AS shipped,
count(*) FILTER (WHERE status IN ('pending', 'paid')) AS open
FROM orders
GROUP BY customer_id
ORDER BY customer_id;
customer_id | orders | shipped | open
-------------+--------+---------+------
1 | 3 | 2 | 1
2 | 2 | 0 | 2
3 | 2 | 1 | 0
4 | 2 | 1 | 1
6 | 2 | 1 | 1
7 | 1 | 1 | 0
One pass over the table, one row per customer, three numbers. This is called conditional aggregation, and it is how you turn rows into a side-by-side summary (sometimes called a pivot).
You will also see it written with CASE, which works in databases without FILTER:
sum(CASE WHEN status = 'shipped' THEN 1 ELSE 0 END) AS shipped
Check yourself
When does a condition belong in
HAVINGrather thanWHERE?Show answer
When it tests an aggregate or the group as a whole, such as
count(*) >= 2.WHEREruns before grouping and cannot use aggregates.Write the aggregate that counts only cancelled orders in each group.
Show answer
count(*) FILTER (WHERE status = 'cancelled'), orsum(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END).A query has
WHERE status <> 'cancelled'and alsocount(*) FILTER (WHERE status = 'cancelled'). What does the filtered count return?Show answer
0 for every group.
WHEREalready removed the cancelled rows before grouping, so the filtered aggregate never sees any.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.PostgreSQL documentation: 2.7 Aggregate Functions (tutorial)Primary· §2.7 Aggregate Functions
WHERE selects input rows before groups and aggregates are computed; HAVING selects group rows after; WHERE must not contain aggregate functions.
- 2.PostgreSQL documentation: 4.2 Value ExpressionsPrimary· §4.2.7 Aggregate Expressions
With FILTER, only input rows for which the filter clause is true are fed to the aggregate.
- 3.PostgreSQL documentation: 9.18 Conditional ExpressionsPrimary· §9.18.1 CASE
CASE chooses a value by condition.
Go further
What to read next, and why it is worth the time.
- 1.PostgreSQL documentation: 2.7 Aggregate Functions (tutorial)Primary· §2.7
The tutorial's explanation of WHERE versus HAVING is the clearest one available.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.