HAVING Clause
Use this lesson to understand HAVING Clause with practical syntax and examples.
Concept Overview
HAVING filters groups after aggregation. If WHERE is “filter raw rows”, then HAVING is “filter the aggregated output”.
Why is it important?
- Correctness: aggregate conditions (like “customers with >= 5 orders”) must be applied after grouping
- Performance: keeping row filters in
WHEREreduces work before grouping - Clarity: separating row filters (
WHERE) from group filters (HAVING) makes queries easier to review
Where does it fit?
HAVING is used with GROUP BY and aggregate functions. The typical query flow is:
WHEREfilters rowsGROUP BYforms groups- aggregates compute metrics per group
HAVINGfilters groupsORDER BYsorts final groups
Syntax & Rules
Core Syntax
SELECT group_key,
aggregate_fn(expr) AS metric
FROM table_name
WHERE row_filters
GROUP BY group_key
HAVING aggregate_predicate
ORDER BY metric DESC;
Available Options / Parameters
| Clause | Evaluated on | Typical use |
|---|---|---|
WHERE | raw rows (before grouping) | WHERE status = 'paid' |
HAVING | grouped rows (after aggregates computed) | HAVING COUNT(*) >= 5 |
Key Rules and Considerations
- Use
WHEREfor non-aggregate filters (status, date windows, region filters). - Use
HAVINGfor conditions on aggregates (COUNT/SUM/AVG). - In PostgreSQL, you generally cannot reference a
SELECTalias insideHAVING. Repeat the expression or use a subquery/CTE. - You can have a
HAVINGclause withoutGROUP BY(it filters the single aggregated result), but it is much more common with grouping.
Step-by-Step Examples
Example 1: Filter Groups by COUNT(*) (Beginner)
CREATE TABLE orders (
order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
status text NOT NULL,
amount numeric(12,2) NOT NULL CHECK (amount >= 0)
);
INSERT INTO orders (customer_id, status, amount) VALUES
(10, 'paid', 25.00),
(10, 'paid', 10.00),
(10, 'failed', 5.00),
(11, 'paid', 50.00),
(11, 'paid', 20.00),
(12, 'paid', 5.00);
-- Customers with at least 2 orders
SELECT customer_id, COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
HAVING COUNT(*) >= 2
ORDER BY customer_id;
Expected output:
customer_id | order_count
-------------+------------
10 | 3
11 | 2
(2 rows)
Explanation:
HAVING COUNT(*) >= 2is evaluated after the groups are formed.
Example 2: Keep Row Filters in WHERE, Group Filters in HAVING (Intermediate)
Goal: customers with at least 2 paid orders and paid revenue >= 40.
SELECT
customer_id,
COUNT(*) AS paid_orders,
SUM(amount) AS paid_revenue
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
HAVING COUNT(*) >= 2
AND SUM(amount) >= 40
ORDER BY paid_revenue DESC;
Expected output:
customer_id | paid_orders | paid_revenue
-------------+------------+-------------
11 | 2 | 70.00
(1 row)
Explanation:
- The
WHERE status = 'paid'filter reduces the rows that participate in the aggregates. HAVINGfilters the grouped results.
Example 3: Conditional Aggregates with FILTER + HAVING (Advanced)
This pattern keeps all rows, computes multiple metrics, then filters groups using HAVING.
SELECT
customer_id,
COUNT(*) AS total_orders,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid
FROM orders
GROUP BY customer_id
HAVING COUNT(*) FILTER (WHERE status = 'paid') >= 2
ORDER BY customer_id;
Expected output:
customer_id | total_orders | paid_orders | revenue_paid
-------------+-------------+------------+-------------
10 | 3 | 2 | 35.00
11 | 2 | 2 | 70.00
(2 rows)
Explanation:
FILTERallows you to write group conditions that are more precise than “use WHERE and lose other rows”.
Example 4: Alias Scope in PostgreSQL (Advanced)
In PostgreSQL, aliases from the SELECT list are not reliably visible in HAVING. Use the aggregate expression or a CTE.
Wrong SQL:
SELECT customer_id, SUM(amount) AS total_spend
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
HAVING total_spend >= 40;
Typical error:
ERROR: column "total_spend" does not exist
Fix option A (repeat the expression):
SELECT customer_id, SUM(amount) AS total_spend
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
HAVING SUM(amount) >= 40;
Fix option B (use a CTE):
WITH totals AS (
SELECT customer_id, SUM(amount) AS total_spend
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
)
SELECT customer_id, total_spend
FROM totals
WHERE total_spend >= 40
ORDER BY total_spend DESC;
Practical Use Cases
1) High-value customers
SELECT customer_id, SUM(amount) AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
HAVING SUM(amount) >= 10000;
2) Products with low review counts
SELECT product_id, COUNT(*) AS review_count
FROM reviews
GROUP BY product_id
HAVING COUNT(*) < 5;
3) Detect duplicate keys in raw data
SELECT external_id, COUNT(*) AS copies
FROM staging_events
GROUP BY external_id
HAVING COUNT(*) > 1;
4) Identify teams with too many open incidents
SELECT team_id, COUNT(*) AS open_incidents
FROM incidents
WHERE status = 'open'
GROUP BY team_id
HAVING COUNT(*) >= 10;
5) Filter groups by average
SELECT course_id, AVG(score) AS avg_score
FROM exam_results
GROUP BY course_id
HAVING AVG(score) BETWEEN 70 AND 90;
Common Mistakes & Troubleshooting
1) Putting aggregate predicates in WHERE
Wrong SQL:
SELECT customer_id, COUNT(*)
FROM orders
WHERE COUNT(*) >= 2
GROUP BY customer_id;
Typical error:
ERROR: aggregate functions are not allowed in WHERE
Fix:
SELECT customer_id, COUNT(*)
FROM orders
GROUP BY customer_id
HAVING COUNT(*) >= 2;
2) Using HAVING for row filters only
Wrong approach:
SELECT customer_id, COUNT(*)
FROM orders
GROUP BY customer_id
HAVING status = 'paid';
Bad outcome:
statusis not aggregated or grouped, so this is invalid (or indicates confused intent).
Fix:
SELECT customer_id, COUNT(*)
FROM orders
WHERE status = 'paid'
GROUP BY customer_id;
3) Forgetting that WHERE changes the population
Bad outcome:
- Filtering rows before grouping changes what “COUNT(*)” means.
Fix:
- Use
FILTERif you need both totals and conditional totals in the same grouped output.
4) Assuming SELECT aliases work in HAVING
Bad outcome:
- PostgreSQL often rejects alias references.
Fix:
- Repeat the expression or wrap the grouped query and filter in an outer SELECT.
Debugging checklist:
- If a predicate depends on an aggregate (COUNT/SUM/AVG), it belongs in
HAVING. - If a predicate depends on raw row columns, it belongs in
WHERE. - If you need both “total” and “paid-only” metrics, consider
FILTER. - If you see alias scope errors, use a CTE/subquery.
- Verify results by comparing a few groups with manual counts.
Best Practices
- ✅ Put row filters in
WHERE. ❌ Avoid usingHAVINGas a replacement forWHERE. - ✅ Put aggregate filters in
HAVING. ❌ Avoid aggregate expressions inWHERE. - ✅ Use
FILTERfor conditional group metrics. ❌ Avoid complicatedSUM(CASE WHEN ... THEN ... END)unless you need portability. - ✅ Alias aggregates for readability. ❌ Avoid repeating long expressions without aliases or CTEs.
- ✅ Use CTEs to reuse computed aggregates in later filtering. ❌ Avoid relying on SELECT alias visibility in PostgreSQL HAVING.
Hands-On Practice
Use this setup for the exercises:
CREATE TABLE practice_orders (
customer_id bigint NOT NULL,
status text NOT NULL,
amount numeric(12,2) NOT NULL
);
INSERT INTO practice_orders (customer_id, status, amount) VALUES
(1, 'paid', 10.00),
(1, 'paid', 15.00),
(2, 'paid', 5.00),
(2, 'failed', 20.00),
(3, 'paid', 50.00);
Exercise 1 (Easy): Customers with 2+ orders
Task: Return customers that have at least 2 orders.
-- Your SQL here
Solution:
SELECT customer_id, COUNT(*) AS orders
FROM practice_orders
GROUP BY customer_id
HAVING COUNT(*) >= 2
ORDER BY customer_id;
Exercise 2 (Medium): Paid-only customers with 2+ paid orders
Task: Return customers that have at least 2 paid orders.
-- Your SQL here
Solution:
SELECT customer_id, COUNT(*) AS paid_orders
FROM practice_orders
WHERE status = 'paid'
GROUP BY customer_id
HAVING COUNT(*) >= 2
ORDER BY customer_id;
Exercise 3 (Advanced): Conditional paid count with FILTER
Task: Return customers where paid_orders >= 2 using FILTER.
-- Your SQL here
Solution:
SELECT
customer_id,
COUNT(*) AS total_orders,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders
FROM practice_orders
GROUP BY customer_id
HAVING COUNT(*) FILTER (WHERE status = 'paid') >= 2
ORDER BY customer_id;
Connection to Other Concepts
| Concept | Why it matters |
|---|---|
GROUP BY | HAVING filters the output of grouped aggregates |
WHERE | filters input rows before aggregation |
| Aggregate functions | HAVING predicates use COUNT/SUM/AVG/etc |
| NULL Values | affects SUM/AVG behavior and conditional aggregates |
| ROLLUP / GROUPING SETS | group-level outputs often need HAVING-like filters |
Visual Learning Diagram
flowchart TD
A[Rows] --> B[WHERE]
B --> C[GROUP BY]
C --> D[Aggregates]
D --> E[HAVING]
E --> F[Final Groups]
classDef allNodes fill:#3e3e3e,stroke:#ffffff,stroke-width:2px,color:#f5f5f5
classDef highlight fill:#3e3e3e,stroke:#ffffff,stroke-width:4px,color:#f5f5f5
class A,B,C,D,E,F allNodes
class E highlight
Common Pitfalls
| Pitfall | Consequence | Prevention |
|---|---|---|
| Aggregate predicate in WHERE | query error | move it to HAVING |
| Row predicate in HAVING | slow/invalid intent | put it in WHERE |
| Using SELECT alias in HAVING | scope error | repeat expression or use a CTE |
| Filtering before grouping unintentionally | wrong population | decide whether to use WHERE vs FILTER |
| Not validating group results | misleading reports | spot-check a few groups |
Quick Reference
SELECT k, COUNT(*) c FROM t GROUP BY k HAVING COUNT(*) > 10;
SELECT k, SUM(v) s FROM t WHERE status = 'ok' GROUP BY k HAVING SUM(v) >= 1000;
SELECT k, AVG(score) a FROM t GROUP BY k HAVING AVG(score) BETWEEN 70 AND 90;
SELECT k, COUNT(*) FILTER (WHERE ok) FROM t GROUP BY k HAVING COUNT(*) FILTER (WHERE ok) > 5;
WITH x AS (SELECT k, SUM(v) s FROM t GROUP BY k) SELECT * FROM x WHERE s > 100;
What's Next
- Previous: GROUP BY - Review the previous lesson to reinforce context.
- Next: GROUP BY with ROLLUP / GROUPING SETS - Continue to the next concept with incremental complexity.
- Module Overview - Return to this module index and choose another related lesson.