Aggregation
HAVING Clause
Filtering aggregated groups based on aggregate conditions.
Interview: HAVING is a classic interview topic — interviewers use it to test whether you understand the difference between WHERE and HAVING, when aggregate filters are evaluated, and how to combine HAVING with complex multi-aggregate conditions.
The HAVING clause filters groups produced by GROUP BY based on conditions that involve aggregate values. While WHERE filters individual rows before grouping, HAVING filters the groups themselves after aggregation has been computed. Think of it as a "WHERE for groups" — it lets you answer questions like "which departments have more than 10 employees?" or "which products have an average rating above 4.0?".
1. Introduction
HAVING is the aggregate-aware filter for grouped results. It runs after GROUP BY computes aggregates, so it can reference COUNT, SUM, AVG, MIN, MAX in its conditions. WHERE cannot do this because it runs before grouping. The mnemonic: "WHERE filters rows, HAVING filters groups."
2. Why It Matters
- Quality filtering: Show only products with 20+ reviews AND 4+ star average — impossible without HAVING.
- Duplicate detection:
HAVING COUNT(*) > 1finds duplicate values grouped by key columns. - Threshold reporting: Departments above a salary benchmark, customers with lifetime value over $10K.
- Performance: Put non-aggregate conditions in WHERE (filters early) and aggregate conditions in HAVING (filters after grouping).
3. Real-World Analogy
HAVING is like a quality inspector who checks batch summaries. The factory first sorts products into batches by type (GROUP BY). Then the inspector checks each batch: "Does this batch have at least 10 items? (HAVING COUNT(*) >= 10)" and "Is the average weight within spec? (HAVING AVG(weight) BETWEEN 95 AND 105)". Batches that fail either check are rejected. The inspector can't examine individual items — they only see the batch summary numbers.
4. How It Works
| Aspect | WHERE | HAVING |
|---|---|---|
| When | Before GROUP BY | After GROUP BY |
| Filters | Individual rows | Groups of rows |
| Aggregates | Not allowed | Allowed |
| Performance | Better (reduces early) | After all grouping done |
Common HAVING patterns:
- Minimum group size:
HAVING COUNT(*) >= N - Aggregate thresholds:
HAVING AVG(salary) > 80000 - Range checks:
HAVING MAX(salary) - MIN(salary) > 50000 - Duplicate detection:
HAVING COUNT(*) > 1 - Subquery comparison:
HAVING AVG(salary) > (SELECT AVG(salary) FROM employees)
5. Internal Architecture
Query: SELECT dept, COUNT(*), AVG(salary) FROM emp GROUP BY dept HAVING COUNT(*) >= 5 AND AVG(salary) > 60000Execution Order: 1. SCAN → Read all rows from employees 2. WHERE → (none) — all rows pass 3. GROUP → Hash/Sort rows by department 4. AGG → Compute COUNT(*), AVG(salary) per group 5. HAVING → Evaluate: COUNT(*) >= 5 AND AVG(salary) > 60000 → Groups failing this condition are DISCARDED 6. SELECT → Project dept, count, avg for surviving groups 7. ORDER → (none)
Key Insight: WHERE → Evaluates BEFORE groups exist → Cannot use aggregates HAVING → Evaluates AFTER groups computed → Can use aggregates
Push non-aggregate filters to WHERE: BAD: HAVING COUNT(*) > 5 AND every_row_has_status_active GOOD: WHERE status = 'active' HAVING COUNT(*) > 5
PostgreSQL allows aggregate aliases in HAVING: HAVING total_orders > 5 (where total_orders = COUNT(*)) This is NOT standard SQL — may not work in other databases.
6. Visual Explanation
7. Practical Example
-- Popular products with good ratings SELECT p.product_name, p.category, COUNT(r.id) AS review_count, ROUND(AVG(r.rating)::numeric, 1) AS avg_rating FROM products p JOIN reviews r ON r.product_id = p.id GROUP BY p.id, p.product_name, p.category HAVING COUNT(r.id) >= 20 AND AVG(r.rating) >= 4.0 ORDER BY review_count DESC;-- Duplicate detection: users with same email SELECT LOWER(email) AS normalized_email, COUNT(*) AS duplicate_count, ARRAY_AGG(id) AS user_ids FROM users GROUP BY LOWER(email) HAVING COUNT(*) > 1 ORDER BY duplicate_count DESC;
-- Departments with high salary variance SELECT department, COUNT(*) AS headcount, ROUND(AVG(salary)::numeric, 2) AS avg_salary, MAX(salary) - MIN(salary) AS salary_range FROM employees WHERE status = 'active' GROUP BY department HAVING COUNT(*) >= 5 AND MAX(salary) - MIN(salary) > 40000 ORDER BY salary_range DESC;
-- Departments above company-wide average salary WITH company_avg AS ( SELECT AVG(salary) AS avg_sal FROM employees WHERE status = 'active' ) SELECT e.department, COUNT(*) AS headcount, ROUND(AVG(e.salary)::numeric, 2) AS dept_avg FROM employees e CROSS JOIN company_avg ca WHERE e.status = 'active' GROUP BY e.department, ca.avg_sal HAVING AVG(e.salary) > ca.avg_sal ORDER BY dept_avg DESC;
8. Common Mistakes
- Using WHERE for aggregate conditions:
WHERE COUNT(*) > 5is invalid. Move to HAVING. - Non-aggregate filters in HAVING:
HAVING status = 'active'works but is slower — push to WHERE for early filtering. - Relying on aliases in HAVING: PostgreSQL allows
HAVING total_orders > 5(alias for COUNT()), but other databases may not. Use the full expression for portable SQL.
Interview Insight
"Find customers with 5+ orders AND $1000+ total spend." Answer: HAVING COUNT(*) > 5 AND SUM(total) > 1000. Interviewers watch for whether you put SUM(total) > 1000 in WHERE (wrong) or HAVING (correct).
Common Pitfall
Column aliases in HAVING: PostgreSQL allows it, but MySQL/SQL Server may not. For portability, repeat the aggregate expression: HAVING COUNT(*) > 5 instead of HAVING order_count > 5.
9. Quick Quiz
Q1: Which clause should contain the condition status = 'active'?
A) WHERE B) HAVING C) Either works, WHERE is better
Answer: C — Either works but WHERE is better because it filters before grouping (less work).
Q2: Can HAVING be used without GROUP BY?
A) No, syntax error B) Yes, it filters the single-row aggregate result C) Only in PostgreSQL
Answer: B — Without GROUP BY, the entire table is one group. HAVING filters that single result.
10. Scenario-Based Challenge
Find Anomalous Departments
Write a query that finds departments where: (1) at least 5 employees, (2) average salary is more than 20% above the company average, (3) the salary range (MAX - MIN) exceeds $50K, AND (4) at least one employee was hired in the last 6 months. Combine WHERE (recent hire filter) with HAVING (aggregate conditions) and a CTE for the company-wide average.
11. Debugging Exercise
This query returns zero rows, but there are definitely departments with more than 10 employees. Why?
-- BUG: Returns zero rows SELECT department, COUNT(*) AS emp_count FROM employees WHERE COUNT(*) > 10 GROUP BY department;-- ERROR: aggregate functions not allowed in WHERE -- PostgreSQL will actually throw a syntax error here
-- FIX: Move aggregate filter to HAVING SELECT department, COUNT(*) AS emp_count FROM employees GROUP BY department HAVING COUNT(*) > 10;
12. Interview Questions
Q: When should you use HAVING instead of WHERE?
A: Use HAVING for conditions involving aggregate functions (COUNT, SUM, AVG, etc.). Use WHERE for conditions on individual row values. If the condition doesn't involve an aggregate, put it in WHERE for better performance.
Q: How do you detect duplicate records using GROUP BY and HAVING?
A: Group by the columns that should be unique, then filter with HAVING COUNT() > 1: SELECT email, COUNT(*) FROM users GROUP BY email HAVING COUNT(*) > 1. Add ARRAY_AGG(id) to see which IDs are duplicated.
Q: Can HAVING reference a subquery?
A: Yes. Example: HAVING AVG(salary) > (SELECT AVG(salary) FROM employees) finds departments above the company average. You can also use CTEs with CROSS JOIN for the same pattern.
13. Production Considerations
- Push filters early: Non-aggregate conditions belong in WHERE, not HAVING. This reduces the rows that need to be grouped, improving performance.
- Combine with CTEs: For complex multi-level filtering (e.g., groups above a computed benchmark), use a CTE for the benchmark and CROSS JOIN it with the main query.
- Monitor HAVING performance: HAVING runs after all groups are computed. If you have millions of groups but only need a few, consider whether a WHERE clause can reduce the input first.
- Portability: Don't rely on alias references in HAVING. Use the full aggregate expression for cross-database compatibility.
Use Cases
Quality filtering — showing only products/categories with sufficient review count and high average ratings
Duplicate detection — finding rows with duplicate values in key columns for data cleanup
Business rules — identifying VIP customers, flagging high-variance departments, detecting anomalous groups
Threshold-based reporting — groups that exceed benchmarks like company averages, industry standards, or SLA targets
Data auditing — finding groups with unexpected characteristics (too few records, too wide a range, missing members)
Common Mistakes
Using WHERE for aggregate conditions — WHERE cannot contain COUNT, SUM, AVG, etc. because it runs before grouping. Move aggregate filters to HAVING
Putting non-aggregate filters in HAVING — conditions like status = 'active' should be in WHERE for better performance (filters before grouping, not after)
Forgetting that HAVING requires GROUP BY (or a query that returns a single aggregate row) — HAVING without GROUP BY is valid but unusual
Relying on column aliases in HAVING — PostgreSQL allows it, but other databases may not. Use the full aggregate expression for portable SQL
Over-filtering with too many HAVING conditions — each condition should have a clear business purpose; excessive filtering can hide valid data patterns