Loading learning content...
Real business requirements rarely boil down to single conditions. A customer isn't 'valuable' just because they spent a lot—they need to be valuable and loyal and active. A product isn't 'problematic' just because sales are low—it depends on margin, returns, and inventory turn.
Complex HAVING filters combine multiple aggregate conditions to express these multi-dimensional requirements. This page teaches you to construct, read, and optimize these compound conditions—the kind you'll encounter in production analytics systems.
You'll learn to think in terms of logical combinations, handle precedence correctly, structure conditions for readability, and debug complex filters when they don't behave as expected.
This page covers: building multi-condition HAVING clauses with AND/OR/NOT, managing operator precedence with parentheses, structuring complex conditions for readability and maintenance, common patterns for business logic, debugging misbehaving filters, and optimization considerations.
Complex HAVING conditions are built by combining simple aggregate conditions using logical operators. The three operators—AND, OR, NOT—allow you to express virtually any logical constraint.
The building blocks:
COUNT(*) > 5)Systematic construction approach:
Example: Step-by-step construction
Business requirement: Find product categories that are either 'high-volume and profitable' OR 'niche and premium'.
Step 1: Enumerate requirements
Step 2: Translate to aggregates
SUM(quantity_sold) >= 1000(SUM(revenue) - SUM(cost)) / SUM(revenue) > 0.2SUM(quantity_sold) < 100AVG(unit_price) > 200Step 3: Determine relationships
Step 4: Write with parentheses
12345678910111213141516171819
SELECT category, SUM(quantity_sold) AS units_sold, AVG(unit_price) AS avg_price, (SUM(revenue) - SUM(cost)) / NULLIF(SUM(revenue), 0) AS marginFROM product_salesGROUP BY categoryHAVING -- Segment 1: High-volume AND profitable ( SUM(quantity_sold) >= 1000 AND (SUM(revenue) - SUM(cost)) / NULLIF(SUM(revenue), 0) > 0.2 ) OR -- Segment 2: Niche AND premium ( SUM(quantity_sold) < 100 AND AVG(unit_price) > 200 );Even when operator precedence would produce the correct result, explicit parentheses prevent misreading. Six months from now, you (or a colleague) will thank yourself for the clarity.
When multiple logical operators appear without parentheses, SQL uses precedence rules to determine evaluation order. Misunderstanding precedence is a common source of bugs in complex filters.
Standard SQL precedence (highest to lowest):
This means A OR B AND C is evaluated as A OR (B AND C), not (A OR B) AND C.
Demonstration: How precedence changes meaning
1234567891011121314151617181920212223
-- SCENARIO: Find departments that are either large, OR (small AND high-paying)-- Intended logic: large departments OR (small AND high-paying) departments -- ❌ WRONG: Missing parentheses, relies on precedence (but may be misread)SELECT department, COUNT(*), AVG(salary)FROM employeesGROUP BY departmentHAVING COUNT(*) > 50 OR COUNT(*) < 10 AND AVG(salary) > 100000;-- This is evaluated as: COUNT(*) > 50 OR (COUNT(*) < 10 AND AVG(salary) > 100000)-- Correct result by luck, but confusing to read -- ✓ CORRECT: Explicit parentheses match intent clearlySELECT department, COUNT(*), AVG(salary)FROM employeesGROUP BY departmentHAVING COUNT(*) > 50 OR (COUNT(*) < 10 AND AVG(salary) > 100000); -- What if we wanted: (large OR small) AND high-paying?SELECT department, COUNT(*), AVG(salary)FROM employeesGROUP BY departmentHAVING (COUNT(*) > 50 OR COUNT(*) < 10) AND AVG(salary) > 100000;-- Completely different meaning—high-paying is now required for all| Expression | Evaluated As | Returns Groups Where... |
|---|---|---|
A OR B AND C | A OR (B AND C) | A is true, OR both B and C are true |
A AND B OR C | (A AND B) OR C | Both A and B are true, OR C is true |
NOT A AND B | (NOT A) AND B | A is false AND B is true |
NOT A OR B | (NOT A) OR B | A is false, OR B is true |
A OR B OR C | (A OR B) OR C | Any of A, B, C is true (left-to-right) |
A AND B AND C | (A AND B) AND C | All of A, B, C are true |
NOT has the highest precedence, which can surprise developers from other languages. NOT A AND B means (NOT A) AND B, not NOT (A AND B). When negating compound conditions, always use parentheses: NOT (A AND B).
Certain patterns recur frequently in business analytics. Recognizing these patterns helps you construct correct queries faster.
Pattern: Groups falling within a specific range on one or more dimensions.
Business example: 'Medium-sized customers with moderate spending'
123456789101112131415161718192021
-- Medium-sized customers: 5-20 orders, $500-$5000 total spendSELECT customer_id, COUNT(*) AS order_count, SUM(order_total) AS total_spentFROM ordersGROUP BY customer_idHAVING COUNT(*) BETWEEN 5 AND 20 AND SUM(order_total) BETWEEN 500 AND 5000; -- Alternative: Using explicit comparison operatorsSELECT customer_id, COUNT(*) AS order_count, SUM(order_total) AS total_spentFROM ordersGROUP BY customer_idHAVING COUNT(*) >= 5 AND COUNT(*) <= 20 AND SUM(order_total) >= 500 AND SUM(order_total) <= 5000;Complex HAVING clauses can become difficult to read and maintain. Consistent formatting improves comprehension and reduces errors.
Formatting guidelines:
-- ❌ POOR: Hard to read and verifySELECT category, SUM(sales)FROM productsGROUP BY categoryHAVING SUM(sales)>10000 AND (COUNT(*)>=5 OR AVG(price)>100) AND NOT (MIN(rating)<2 AND COUNT(*)<10);-- ✓ GOOD: Clear structureSELECT category, SUM(sales)FROM productsGROUP BY categoryHAVING -- Minimum sales threshold SUM(sales) > 10000 -- AND (variety OR premium) AND ( COUNT(*) >= 5 OR AVG(price) > 100 ) -- AND NOT poorly-rated niche AND NOT ( MIN(rating) < 2 AND COUNT(*) < 10 );Using CTEs to simplify complex HAVING:
For extremely complex conditions, consider computing intermediate aggregates in a CTE, then filtering with a simpler WHERE:
123456789101112131415161718192021222324252627282930
-- Complex logic separated into readable CTEWITH category_metrics AS ( SELECT category, SUM(sales) AS total_sales, COUNT(*) AS product_count, AVG(price) AS avg_price, MIN(rating) AS min_rating FROM products GROUP BY category),category_flags AS ( SELECT category, total_sales, product_count, avg_price, min_rating, -- Derive meaningful boolean flags (total_sales > 10000) AS is_high_sales, (product_count >= 5 OR avg_price > 100) AS is_viable, (min_rating < 2 AND product_count < 10) AS is_risky_niche FROM category_metrics)SELECT *FROM category_flagsWHERE is_high_sales AND is_viable AND NOT is_risky_niche;For queries with 5+ HAVING conditions, CTEs often improve readability. Pre-computing aggregates and deriving boolean flags makes the final filter trivially simple. The query optimizer typically handles this efficiently.
When a complex HAVING clause doesn't return expected results, systematic debugging quickly identifies the issue.
Debugging technique: Remove HAVING and inspect
123456789101112131415161718192021222324252627
-- Step 1: Run query WITHOUT HAVING to see all groups and their aggregatesSELECT category, SUM(sales) AS total_sales, COUNT(*) AS product_count, AVG(price) AS avg_price, MIN(rating) AS min_ratingFROM productsGROUP BY category-- HAVING conditions removed temporarilyORDER BY category; -- Step 2: Add your HAVING conditions one at a time-- First condition only:SELECT category, SUM(sales), COUNT(*), AVG(price), MIN(rating)FROM productsGROUP BY categoryHAVING SUM(sales) > 10000; -- How many survive? -- Add second condition:SELECT category, SUM(sales), COUNT(*), AVG(price), MIN(rating)FROM productsGROUP BY categoryHAVING SUM(sales) > 10000 AND COUNT(*) >= 5; -- How many now? -- Continue adding until you find the culprit conditionDebugging technique: Add condition flags to output
1234567891011121314151617
-- Add boolean columns showing which conditions each group satisfiesSELECT category, SUM(sales) AS total_sales, COUNT(*) AS product_count, AVG(price) AS avg_price, MIN(rating) AS min_rating, -- Condition flags (SUM(sales) > 10000) AS passes_sales_check, (COUNT(*) >= 5) AS passes_count_check, (AVG(price) > 100) AS passes_price_check, (MIN(rating) < 2) AS has_low_rating, -- Combined logic (SUM(sales) > 10000 AND (COUNT(*) >= 5 OR AVG(price) > 100)) AS passes_allFROM productsGROUP BY categoryORDER BY passes_all DESC, category;If an aggregate returns NULL (e.g., SUM of a column where all values are NULL), any comparison like SUM(x) > 100 evaluates to NULL, not FALSE. Groups with NULL condition results are excluded. Use COALESCE to handle: HAVING COALESCE(SUM(x), 0) > 100.
Complex HAVING conditions can impact query performance. Understanding these impacts helps you write efficient queries.
Key performance principles:
12345678910111213141516171819
-- ❌ INEFFICIENT: Filtering on GROUP BY column in HAVINGSELECT region, SUM(sales)FROM ordersGROUP BY regionHAVING region IN ('East', 'West', 'North') -- Could be in WHERE! AND SUM(sales) > 100000; -- ✓ EFFICIENT: Non-aggregate condition moved to WHERESELECT region, SUM(sales)FROM ordersWHERE region IN ('East', 'West', 'North') -- Filters earlyGROUP BY regionHAVING SUM(sales) > 100000; -- The efficient version:-- 1. May use an index on 'region' -- 2. Groups only 3 regions instead of all regions-- 3. Aggregates less data-- 4. Then filters on the aggregateWhen subqueries in HAVING are unavoidable:
If you need to compare aggregates to global statistics, consider pre-computing the statistics:
12345678910111213141516171819202122232425
-- ❌ SLOW: Subquery in HAVING computed for each groupSELECT department, AVG(salary)FROM employeesGROUP BY departmentHAVING AVG(salary) > (SELECT AVG(salary) FROM employees); -- ✓ FASTER: Pre-compute the global average with a CTEWITH global_stats AS ( SELECT AVG(salary) AS global_avg_salary FROM employees)SELECT department, AVG(salary) AS dept_avgFROM employeesCROSS JOIN global_statsGROUP BY department, global_avg_salaryHAVING AVG(salary) > global_avg_salary; -- Alternative: Use a scalar subquery that the optimizer can cacheSELECT department, AVG(salary)FROM employeesGROUP BY departmentHAVING AVG(salary) > ( SELECT AVG(salary) FROM employees -- Most optimizers compute this once, not per-group);Let's examine some production-grade HAVING clauses that demonstrate how complex business logic translates to SQL:
Business requirement: Identify customers at risk based on multiple behavioral signals—declining order frequency, reduced spending, and lack of engagement.
12345678910111213141516171819202122232425262728293031323334353637383940
SELECT customer_id, COUNT(*) AS total_orders, SUM(order_total) AS lifetime_value, MAX(order_date) AS last_order, -- Recent vs historical comparison COUNT(CASE WHEN order_date > CURRENT_DATE - 90 THEN 1 END) AS orders_last_90d, COUNT(CASE WHEN order_date BETWEEN CURRENT_DATE - 180 AND CURRENT_DATE - 90 THEN 1 END) AS orders_prev_90d, AVG(CASE WHEN order_date > CURRENT_DATE - 90 THEN order_total END) AS recent_avg, AVG(CASE WHEN order_date <= CURRENT_DATE - 90 THEN order_total END) AS historical_avgFROM ordersWHERE status = 'completed'GROUP BY customer_idHAVING -- Has meaningful history COUNT(*) >= 5 AND MAX(order_date) > CURRENT_DATE - 365 AND ( -- Risk signal 1: Declining frequency ( COUNT(CASE WHEN order_date > CURRENT_DATE - 90 THEN 1 END) < COUNT(CASE WHEN order_date BETWEEN CURRENT_DATE - 180 AND CURRENT_DATE - 90 THEN 1 END) * 0.5 ) OR -- Risk signal 2: Declining order value ( AVG(CASE WHEN order_date > CURRENT_DATE - 90 THEN order_total END) < AVG(CASE WHEN order_date <= CURRENT_DATE - 90 THEN order_total END) * 0.7 ) OR -- Risk signal 3: Extended inactivity for previously active customer ( MAX(order_date) < CURRENT_DATE - 60 AND COUNT(*) / (EXTRACT(DAYS FROM CURRENT_DATE - MIN(order_date)) / 30.0) > 1 ) );Complex HAVING filters enable sophisticated multi-dimensional analysis of grouped data. Let's consolidate the key concepts:
What's next:
The final piece of the HAVING puzzle is understanding exactly where it fits in SQL's execution order—and how that affects what you can reference and how filters interact. The next page provides a comprehensive look at execution order and its practical implications.
You can now construct, read, and debug complex multi-condition HAVING clauses. From simple conjunctions to nested multi-segment filters with ratio calculations, you have the tools to express sophisticated business logic in SQL. The final page solidifies your understanding of HAVING's position in execution order.