Loading content...
The HAVING clause unlocks its true power through aggregate conditions—expressions that evaluate summarized data to determine which groups survive. These conditions can be surprisingly sophisticated, combining multiple aggregates, using comparison operators, and expressing complex business logic.
In this page, we systematically explore what's possible in HAVING conditions. You'll learn how to express conditions on counts, sums, averages, minimums, maximums, and combinations thereof. You'll see how to compare aggregate values against constants, other aggregates, subqueries, and computed expressions.
By the end, you'll be able to express virtually any aggregate constraint your business logic requires.
This page comprehensively covers aggregate conditions: the standard aggregate functions available, comparison operators, combining conditions with logical operators, comparing aggregates to each other, using subqueries in HAVING, and expressing complex business rules as aggregate constraints.
SQL provides a core set of aggregate functions that work identically in HAVING as they do in SELECT. Let's examine each with HAVING-specific examples:
| Function | Description | NULL Handling | Common HAVING Use |
|---|---|---|---|
COUNT(*) | Counts all rows in each group | Counts rows with NULLs | Minimum group size thresholds |
COUNT(column) | Counts non-NULL values in column | Ignores NULL values | Data completeness requirements |
COUNT(DISTINCT column) | Counts unique non-NULL values | Ignores NULL values | Variety/diversity thresholds |
SUM(column) | Totals numeric values | Ignores NULL values | Revenue/quantity thresholds |
AVG(column) | Calculates mean of values | Ignores NULL values | Average value constraints |
MIN(column) | Finds smallest value | Ignores NULL values | Lower bound constraints |
MAX(column) | Finds largest value | Ignores NULL values | Upper bound constraints |
Examples of each in HAVING:
1234567891011121314151617181920212223242526272829303132333435363738394041
-- COUNT(*): Groups with at least 10 membersSELECT department, COUNT(*) AS employee_countFROM employeesGROUP BY departmentHAVING COUNT(*) >= 10; -- COUNT(column): Groups where at least 5 employees have phone numbersSELECT department, COUNT(phone) AS with_phoneFROM employeesGROUP BY departmentHAVING COUNT(phone) >= 5; -- COUNT(DISTINCT): Departments with at least 3 unique job titlesSELECT department, COUNT(DISTINCT job_title) AS unique_rolesFROM employeesGROUP BY departmentHAVING COUNT(DISTINCT job_title) >= 3; -- SUM: Product categories with total sales over $100,000SELECT category, SUM(sales_amount) AS total_salesFROM productsGROUP BY categoryHAVING SUM(sales_amount) > 100000; -- AVG: Departments with average salary above $75,000SELECT department, AVG(salary) AS avg_salaryFROM employeesGROUP BY departmentHAVING AVG(salary) > 75000; -- MIN: Suppliers where the cheapest product is still over $50SELECT supplier, MIN(unit_price) AS min_priceFROM productsGROUP BY supplierHAVING MIN(unit_price) > 50; -- MAX: Categories where the most expensive item is under $1000SELECT category, MAX(unit_price) AS max_priceFROM productsGROUP BY categoryHAVING MAX(unit_price) < 1000;Use COUNT(*) when you want to count rows regardless of NULLs—'how many records are in this group?' Use COUNT(column) when you specifically need to count non-NULL values—'how many records have this data?' The distinction matters for data quality analysis.
HAVING conditions use the same comparison operators as WHERE, but applied to aggregate values. Let's review the complete set:
| Operator | Meaning | Example |
|---|---|---|
= | Equal to | HAVING COUNT(*) = 5 |
<> or != | Not equal to | HAVING AVG(score) <> 0 |
< | Less than | HAVING SUM(quantity) < 100 |
<= | Less than or equal | HAVING MAX(price) <= 50 |
> | Greater than | HAVING COUNT(*) > 10 |
>= | Greater than or equal | HAVING MIN(rating) >= 4 |
BETWEEN | Within inclusive range | HAVING AVG(age) BETWEEN 25 AND 35 |
IN | Matches any in list | HAVING COUNT(*) IN (5, 10, 15) |
IS NULL | Aggregate is NULL | HAVING SUM(value) IS NULL |
IS NOT NULL | Aggregate is not NULL | HAVING AVG(score) IS NOT NULL |
Important notes on aggregate NULL handling:
1234567891011121314151617
-- Find groups where no commission data exists (all NULL)SELECT department, SUM(commission) AS total_commissionFROM employeesGROUP BY departmentHAVING SUM(commission) IS NULL; -- Find groups where at least some commission data existsSELECT department, SUM(commission) AS total_commissionFROM employeesGROUP BY departmentHAVING SUM(commission) IS NOT NULL; -- Using COALESCE to treat NULL aggregates as zeroSELECT department, COALESCE(SUM(commission), 0) AS total_commissionFROM employeesGROUP BY departmentHAVING COALESCE(SUM(commission), 0) > 10000;Complex HAVING conditions combine multiple aggregate constraints using logical operators: AND, OR, and NOT. These work exactly as in WHERE, but each operand is typically an aggregate expression.
Operator precedence: NOT has highest precedence, then AND, then OR. Use parentheses to clarify intent.
AND requires both conditions to be true. Groups must satisfy all criteria.
12345678910111213141516171819
-- Departments with 5+ employees AND average salary > $60,000SELECT department, COUNT(*) AS emp_count, AVG(salary) AS avg_salaryFROM employeesGROUP BY departmentHAVING COUNT(*) >= 5 AND AVG(salary) > 60000; -- Products with high volume AND high revenueSELECT product_id, SUM(quantity) AS total_units, SUM(quantity * unit_price) AS total_revenueFROM order_itemsGROUP BY product_idHAVING SUM(quantity) > 1000 AND SUM(quantity * unit_price) > 50000;HAVING isn't limited to comparing aggregates against constants. You can compare aggregates to other aggregates, enabling sophisticated analytical conditions.
Pattern: Aggregate compared to expression of aggregates
12345678910111213141516171819202122232425262728293031323334353637
-- 1. Spread analysis: groups where max is at least 2x the minSELECT category, MIN(unit_price) AS min_price, MAX(unit_price) AS max_priceFROM productsGROUP BY categoryHAVING MAX(unit_price) >= 2 * MIN(unit_price); -- 2. Data quality: groups where count of filled values < count of all rows-- (indicates groups with missing data)SELECT department, COUNT(*) AS total_employees, COUNT(phone) AS with_phone, COUNT(*) - COUNT(phone) AS missing_phoneFROM employeesGROUP BY departmentHAVING COUNT(phone) < COUNT(*); -- 3. Average vs median proxy: groups where max is far from averageSELECT category, AVG(unit_price) AS avg_price, MAX(unit_price) AS max_priceFROM productsGROUP BY categoryHAVING MAX(unit_price) > 3 * AVG(unit_price); -- Outlier detection -- 4. Revenue concentration: products where single largest order is > 20% of totalSELECT product_id, SUM(quantity) AS total_units, MAX(quantity) AS max_single_orderFROM order_itemsGROUP BY product_idHAVING MAX(quantity) > 0.2 * SUM(quantity);Comparing aggregates enables ratio-based filtering: 'groups where X is Y% of Z.' This is essential for penetration analysis, completion rates, concentration metrics, and many business KPIs.
More examples of inter-aggregate comparisons:
1234567891011121314151617181920212223242526272829
-- Customers where average order is less than their first order (declining value)SELECT customer_id, MIN(order_date) AS first_order_date, AVG(order_total) AS avg_order, -- Using window functions or subqueries for first order value is complex, -- but simpler: compare to min/max as proxy MAX(order_total) AS max_orderFROM ordersGROUP BY customer_idHAVING AVG(order_total) < MAX(order_total) * 0.5; -- Avg < half of max -- Suppliers with high variety: distinct products > 10% of total productsSELECT supplier_id, COUNT(*) AS total_products, COUNT(DISTINCT category) AS distinct_categoriesFROM productsGROUP BY supplier_idHAVING COUNT(DISTINCT category) >= 0.1 * COUNT(*); -- Time-based: periods where max value is more than double the averageSELECT DATE_TRUNC('month', sale_date) AS month, AVG(sale_amount) AS avg_sale, MAX(sale_amount) AS max_saleFROM salesGROUP BY DATE_TRUNC('month', sale_date)HAVING MAX(sale_amount) > 2 * AVG(sale_amount);HAVING conditions can include arithmetic operations on aggregates, enabling computed thresholds and derived metrics.
12345678910111213141516171819202122232425262728293031323334353637
-- Profit margin: (revenue - cost) / revenue > 30%SELECT product_id, SUM(quantity * unit_price) AS revenue, SUM(quantity * unit_cost) AS cost, (SUM(quantity * unit_price) - SUM(quantity * unit_cost)) / NULLIF(SUM(quantity * unit_price), 0) AS marginFROM order_itemsGROUP BY product_idHAVING (SUM(quantity * unit_price) - SUM(quantity * unit_cost)) / NULLIF(SUM(quantity * unit_price), 0) > 0.3; -- Average order value greater than $50 after $10 discountSELECT customer_id, AVG(order_total - 10) AS avg_discounted_valueFROM ordersGROUP BY customer_idHAVING AVG(order_total - 10) > 50; -- Groups where standard deviation proxy (max - min) exceeds thresholdSELECT region, MAX(temperature) - MIN(temperature) AS temp_rangeFROM weather_readingsGROUP BY regionHAVING MAX(temperature) - MIN(temperature) > 30; -- Weighted average: total spent / count > $100SELECT customer_id, SUM(order_total) AS total_spent, COUNT(*) AS order_count, SUM(order_total) / COUNT(*) AS avg_order_valueFROM ordersGROUP BY customer_idHAVING SUM(order_total) / COUNT(*) > 100;When dividing aggregates, use NULLIF(denominator, 0) to avoid division by zero errors. This converts zero to NULL, making the result NULL instead of an error. Consider whether NULL groups should be filtered out or handled specially.
HAVING conditions can reference scalar subqueries—queries that return a single value. This enables dynamic thresholds based on data rather than hardcoded constants.
Pattern: Compare to overall aggregate
1234567891011121314151617181920212223242526272829303132333435363738394041424344
-- Departments with above-average employee countSELECT department, COUNT(*) AS emp_countFROM employeesGROUP BY departmentHAVING COUNT(*) > ( SELECT AVG(dept_count) FROM ( SELECT COUNT(*) AS dept_count FROM employees GROUP BY department ) subq); -- Categories with total sales above the median categorySELECT category, SUM(sale_amount) AS total_salesFROM salesGROUP BY categoryHAVING SUM(sale_amount) > ( SELECT AVG(cat_total) FROM ( SELECT SUM(sale_amount) AS cat_total FROM sales GROUP BY category ) subq); -- Products with order count exceeding the top 10% thresholdSELECT product_id, COUNT(*) AS order_countFROM order_itemsGROUP BY product_idHAVING COUNT(*) > ( SELECT PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY cnt) FROM ( SELECT COUNT(*) AS cnt FROM order_items GROUP BY product_id ) subq);Pattern: Reference another table's aggregate
12345678910111213141516171819202122232425262728293031323334353637
-- Customers whose average order exceeds company-wide averageSELECT customer_id, AVG(order_total) AS customer_avgFROM ordersGROUP BY customer_idHAVING AVG(order_total) > ( SELECT AVG(order_total) FROM orders); -- Regions meeting corporate sales target (stored in settings table)SELECT region, SUM(sales_amount) AS regional_salesFROM salesGROUP BY regionHAVING SUM(sales_amount) >= ( SELECT target_value FROM corporate_targets WHERE target_name = 'regional_sales_minimum'); -- Products with rating above the category averageSELECT p.product_id, p.category, AVG(r.rating) AS product_ratingFROM products pJOIN reviews r ON p.product_id = r.product_idGROUP BY p.product_id, p.categoryHAVING AVG(r.rating) > ( SELECT AVG(r2.rating) FROM products p2 JOIN reviews r2 ON p2.product_id = r2.product_id WHERE p2.category = p.category);The last example uses a correlated subquery—the subquery references the outer query's group (p.category). This evaluates the subquery for each group, comparing each product to its category average. Powerful but can be performance-intensive.
The CASE expression inside aggregate functions enables conditional aggregation—counting or summing only values meeting certain criteria. These conditional aggregates can then be used in HAVING.
1234567891011121314151617181920212223242526272829303132333435
-- Departments where over 50% of employees are senior (salary > 80000)SELECT department, COUNT(*) AS total, COUNT(CASE WHEN salary > 80000 THEN 1 END) AS senior_countFROM employeesGROUP BY departmentHAVING COUNT(CASE WHEN salary > 80000 THEN 1 END) > COUNT(*) * 0.5; -- Alternative using SUM for the same logicSELECT department, COUNT(*) AS total, SUM(CASE WHEN salary > 80000 THEN 1 ELSE 0 END) AS senior_countFROM employeesGROUP BY departmentHAVING SUM(CASE WHEN salary > 80000 THEN 1 ELSE 0 END) > COUNT(*) * 0.5; -- Categories where more items are on sale than at full priceSELECT category, COUNT(CASE WHEN discount > 0 THEN 1 END) AS on_sale, COUNT(CASE WHEN discount = 0 THEN 1 END) AS full_priceFROM productsGROUP BY categoryHAVING COUNT(CASE WHEN discount > 0 THEN 1 END) > COUNT(CASE WHEN discount = 0 THEN 1 END); -- Customers with at least 3 high-value orders (> $500)SELECT customer_id, COUNT(CASE WHEN order_total > 500 THEN 1 END) AS high_value_ordersFROM ordersGROUP BY customer_idHAVING COUNT(CASE WHEN order_total > 500 THEN 1 END) >= 3;Using FILTER clause (SQL standard, supported in PostgreSQL):
Some databases support the FILTER clause as a cleaner alternative to CASE in aggregates:
1234567891011121314151617181920
-- PostgreSQL: More readable conditional aggregates with FILTERSELECT department, COUNT(*) AS total, COUNT(*) FILTER (WHERE salary > 80000) AS senior_countFROM employeesGROUP BY departmentHAVING COUNT(*) FILTER (WHERE salary > 80000) > COUNT(*) * 0.5; -- Multiple filtered aggregatesSELECT year, SUM(revenue) FILTER (WHERE quarter = 1) AS q1_revenue, SUM(revenue) FILTER (WHERE quarter = 2) AS q2_revenue, SUM(revenue) FILTER (WHERE quarter = 3) AS q3_revenue, SUM(revenue) FILTER (WHERE quarter = 4) AS q4_revenueFROM quarterly_salesGROUP BY yearHAVING SUM(revenue) FILTER (WHERE quarter = 4) > SUM(revenue) FILTER (WHERE quarter = 1); -- Q4 > Q1 growthFILTER is more readable than CASE for conditional aggregation. However, CASE works everywhere (including MySQL, SQL Server, Oracle). Use FILTER in PostgreSQL for clarity; use CASE when you need portability.
Let's see how aggregate conditions translate complex business rules into HAVING clauses:
Business Rule: Identify at-risk customers who made purchases in the past but have been inactive for 90+ days and whose recent average order is declining.
12345678910111213
SELECT customer_id, COUNT(*) AS total_orders, MAX(order_date) AS last_order, AVG(order_total) AS avg_order, AVG(CASE WHEN order_date > CURRENT_DATE - 180 THEN order_total END) AS recent_avgFROM ordersGROUP BY customer_idHAVING COUNT(*) >= 3 -- Was active (3+ orders) AND MAX(order_date) < CURRENT_DATE - 90 -- No orders in 90 days AND AVG(CASE WHEN order_date > CURRENT_DATE - 180 THEN order_total END) < AVG(order_total) * 0.7; -- Recent avg < 70% of lifetime avgAggregate conditions in HAVING enable sophisticated group filtering based on computed summaries. Let's consolidate what we've covered:
What's next:
With individual aggregate conditions mastered, we'll explore combining multiple conditions into complex filters—handling intricate business logic with AND, OR, NOT, and nested expressions for multi-dimensional analysis.
You now command the full vocabulary of aggregate conditions in HAVING. From simple threshold checks to complex ratio analyses with subqueries and conditional expressions, you can translate virtually any aggregate constraint into SQL. Next, we'll tackle combining these into complex multi-condition filters.