MySQLHAVING

HAVING in MySQL

HAVING filters the results of a GROUP BY query. While WHERE filters individual rows before they are grouped, HAVING filters the resulting groups after aggregation. It is the only place where you can use aggregate functions in a filter condition.

HAVING Syntax

SQL
SELECT column, aggregate_function(col)
FROM table
WHERE row_filter
GROUP BY column
HAVING group_filter
ORDER BY column
LIMIT n;
HAVING vs WHERE — Which Runs First

This is the most important concept to understand about HAVING. The logical execution order is:

  1. FROM / JOIN — identify the tables
  2. WHERE — filter individual rows
  3. GROUP BY — divide remaining rows into groups
  4. Aggregate functions — compute one value per group
  5. HAVING — filter the groups
  6. SELECT — pick the output columns
  7. ORDER BY — sort
  8. LIMIT — truncate

This means WHERE cannot reference aggregate results, and HAVING cannot filter based on non-aggregated columns that are not in the GROUP BY.

SQL
-- ERROR: cannot use aggregate function in WHERE
SELECT customer_id, COUNT(*) AS orders
FROM orders
WHERE COUNT(*) > 5       -- ERROR: Invalid use of group function
GROUP BY customer_id;

-- Correct: use HAVING for aggregate conditions
SELECT customer_id, COUNT(*) AS orders
FROM orders
GROUP BY customer_id
HAVING COUNT(*) > 5;
Warning
If you accidentally put an aggregate condition in WHERE, MySQL throws "Invalid use of group function". The fix is always to move that condition to HAVING.
Basic HAVING Examples

SQL
-- Customers who have placed more than 3 orders
SELECT
  customer_id,
  COUNT(*)             AS order_count,
  ROUND(SUM(total), 2) AS total_spent
FROM orders
WHERE status = 'delivered'
GROUP BY customer_id
HAVING order_count > 3
ORDER BY total_spent DESC;

-- Product categories with average price above $50
SELECT
  category_id,
  COUNT(*)           AS products,
  ROUND(AVG(price), 2) AS avg_price,
  ROUND(MIN(price), 2) AS min_price,
  ROUND(MAX(price), 2) AS max_price
FROM products
WHERE is_active = 1
GROUP BY category_id
HAVING avg_price > 50
ORDER BY avg_price DESC;
Note
MySQL allows HAVING to reference aliases defined in the SELECT list (e.g., HAVING order_count > 3). This is a MySQL extension — standard SQL requires repeating the expression: HAVING COUNT(*) > 3. Both work in MySQL.
HAVING with Aggregate Functions

SQL
-- Find customers who spent more than $500 total on delivered orders
SELECT
  c.customer_id,
  CONCAT(c.first_name, ' ', c.last_name)  AS name,
  COUNT(o.order_id)                        AS orders,
  ROUND(SUM(o.total), 2)                   AS lifetime_value
FROM customers AS c
JOIN orders    AS o ON c.customer_id = o.customer_id
WHERE o.status = 'delivered'
GROUP BY c.customer_id, name
HAVING SUM(o.total) > 500
ORDER BY lifetime_value DESC;

-- Departments where the average salary exceeds $80,000
SELECT
  department,
  COUNT(*)                  AS headcount,
  ROUND(AVG(salary), 0)     AS avg_salary,
  ROUND(MIN(salary), 0)     AS min_salary,
  ROUND(MAX(salary), 0)     AS max_salary
FROM employees
WHERE is_active = 1
GROUP BY department
HAVING AVG(salary) > 80000
ORDER BY avg_salary DESC;
Combining WHERE and HAVING

The two clauses serve different purposes and can coexist in the same query. Use WHERE to pre-filter rows (reduces the data to group), and HAVING to post-filter groups.

SQL
-- WHERE filters rows first, then HAVING filters groups
SELECT
  c.country,
  COUNT(DISTINCT c.customer_id)         AS customers,
  COUNT(o.order_id)                     AS total_orders,
  ROUND(SUM(o.total), 2)                AS revenue
FROM customers AS c
JOIN orders    AS o ON c.customer_id = o.customer_id
WHERE o.status = 'delivered'                   -- row filter (before grouping)
  AND o.created_at >= '2024-01-01'             -- row filter
GROUP BY c.country
HAVING COUNT(DISTINCT c.customer_id) >= 10     -- group filter (after aggregation)
   AND SUM(o.total) > 1000                     -- group filter
ORDER BY revenue DESC;
Tip
Put as much filtering as possible in WHERE rather than HAVING. WHERE reduces the row count before grouping, which means fewer rows to aggregate and sort. HAVING filters can only run after all the work is done.
HAVING Without GROUP BY

HAVING can be used without GROUP BY. In this case, the entire table is treated as one group. It is rarely useful but technically valid.

SQL
-- Returns the row only if the table has more than 100 orders total
SELECT COUNT(*) AS total_orders
FROM orders
HAVING COUNT(*) > 100;

-- More practically: return summary stats only if enough data exists
SELECT
  ROUND(AVG(total), 2) AS avg_order,
  COUNT(*)             AS sample_size
FROM orders
WHERE status = 'delivered'
HAVING COUNT(*) >= 30;   -- only report if we have a meaningful sample size
HAVING with Column Aliases

SQL
-- MySQL allows referencing SELECT aliases in HAVING
SELECT
  customer_id,
  COUNT(*)             AS order_count,
  ROUND(SUM(total), 2) AS total_spent,
  ROUND(AVG(total), 2) AS avg_order
FROM orders
WHERE status = 'delivered'
GROUP BY customer_id
HAVING order_count >= 5          -- uses alias defined in SELECT
   AND avg_order   >= 50.00      -- uses alias defined in SELECT
ORDER BY total_spent DESC;
Practical Examples — Finding High-Value Groups

SQL
-- Top product categories by revenue in Q1 2024
SELECT
  cat.name                                    AS category,
  COUNT(DISTINCT o.order_id)                  AS orders,
  SUM(oi.quantity)                            AS units,
  ROUND(SUM(oi.quantity * oi.unit_price), 2)  AS revenue
FROM categories  AS cat
JOIN products    AS p   ON cat.category_id = p.category_id
JOIN order_items AS oi  ON p.product_id    = oi.product_id
JOIN orders      AS o   ON oi.order_id     = o.order_id
WHERE o.status = 'delivered'
  AND o.created_at BETWEEN '2024-01-01' AND '2024-03-31'
GROUP BY cat.category_id, cat.name
HAVING revenue > 5000
ORDER BY revenue DESC;

-- Countries with more than 5 customers who each spent over $200
SELECT
  c.country,
  COUNT(DISTINCT c.customer_id) AS high_value_customers
FROM customers AS c
JOIN orders    AS o ON c.customer_id = o.customer_id
WHERE o.status = 'delivered'
GROUP BY c.country, c.customer_id        -- group per customer first
HAVING SUM(o.total) > 200               -- filter to high-spenders only
-- Now wrap in an outer query to count per country
;

-- Correct two-step approach using a subquery
SELECT country, COUNT(*) AS high_value_customers
FROM (
  SELECT c.country, c.customer_id
  FROM customers AS c
  JOIN orders    AS o ON c.customer_id = o.customer_id
  WHERE o.status = 'delivered'
  GROUP BY c.country, c.customer_id
  HAVING SUM(o.total) > 200
) AS spenders
GROUP BY country
HAVING COUNT(*) >= 5
ORDER BY high_value_customers DESC;
HAVING with ROLLUP

SQL
-- Revenue by category, only categories over $10,000, plus grand total
SELECT
  COALESCE(cat.name, 'GRAND TOTAL') AS category,
  ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue
FROM order_items AS oi
JOIN products    AS p   ON oi.product_id = p.product_id
JOIN categories  AS cat ON p.category_id = cat.category_id
JOIN orders      AS o   ON oi.order_id   = o.order_id
WHERE o.status = 'delivered'
GROUP BY cat.name WITH ROLLUP
HAVING GROUPING(cat.name) = 1        -- always include the ROLLUP grand total
    OR SUM(oi.quantity * oi.unit_price) > 10000;
HAVING Performance

HAVING runs after aggregation, so the full GROUP BY computation must complete before HAVING can filter. For large tables, pre-filtering with WHERE is significantly faster. Move any condition that does not require an aggregate result into WHERE.

SQL
-- Slow: filters all rows by country AFTER aggregation
SELECT country, COUNT(*) AS cnt
FROM orders
GROUP BY country
HAVING country = 'Canada';       -- inefficient: scans all rows, then filters

-- Fast: filter country BEFORE aggregation
SELECT country, COUNT(*) AS cnt
FROM orders
WHERE country = 'Canada'         -- efficient: index on country filters first
GROUP BY country;
HAVING Clause Quick Reference

Aspect

WHERE

HAVING

Runs at

Before GROUP BY

After GROUP BY and aggregation

Can filter on aggregates

No

Yes

Can reference SELECT aliases

No

Yes (MySQL extension)

Can filter non-grouped cols

Yes

Technically yes but wrong to rely on it

Performance impact

Reduces rows before aggregation

Filters after full aggregation cost

Required with GROUP BY

No (optional)

No (optional)

  • Use WHERE to filter rows, HAVING to filter groups

  • Put non-aggregate conditions in WHERE for better performance

  • HAVING is the only place to filter by aggregate results like COUNT or SUM

  • MySQL allows HAVING to reference SELECT aliases — standard SQL does not

  • Combine WHERE and HAVING freely in the same query when both types of filtering are needed