🇯🇵 日本語 | 🇺🇸 English | 🇪🇸 Español | 🇵🇹 Português | 🇹🇭 ไทย | 🇨🇳 中文

Master the SQL HAVING Clause: A Deep Dive into Filtering Grouped Data

Once you get the hang of SQL's GROUP BY clause, you can perform useful aggregations like counting products per category or orders per customer. But data analysis truly begins from that point. You'll inevitably encounter situations where you want to filter further based on those aggregated results, asking questions like, "Who are our top customers with the most orders?" or "Which popular categories have the highest average price?"

The solution to this "filtering after grouping" is today's main topic: the HAVING clause. When used with GROUP BY, HAVING dramatically improves the precision and depth of your data analysis. It's the perfect tool that addresses exactly what you need.

This article will thoroughly explain the role and usage of the HAVING clause from scratch, focusing on its difference from the WHERE clause, a common point of confusion for many SQL learners. Through numerous "copy-paste-and-run" examples (complete with an online SQL environment!), you'll have that "Aha!" moment and experience how it works firsthand, taking your data aggregation skills to the next stage!


Preparation: Let's Set Up Our Sample Order Data

Before diving into theory, let's build a practical foundation. We'll be using an orders table that mimics an e-commerce site's order history. It contains practical data like customer ID, product category, price, and order date. You can copy the entire SQL statement below to run in your own environment or use it in the "Interactive Playground" section later on.

-- Drop the table if it exists (to allow for repeated tests)
DROP TABLE IF EXISTS orders;

-- Create the orders table
CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL,
  product_category TEXT NOT NULL,
  price INTEGER NOT NULL,
  order_date DATE NOT NULL
);

-- Insert data
INSERT INTO orders (order_id, customer_id, product_category, price, order_date) VALUES
(1, 101, 'PC Peripherals', 15000, '2025-05-10'),
(2, 102, 'Books', 3200, '2025-05-11'),
(3, 101, 'PC Peripherals', 250000, '2025-05-12'),
(4, 103, 'Appliances', 88000, '2025-05-15'),
(5, 102, 'Books', 4500, '2025-05-20'),
(6, 101, 'Appliances', 120000, '2025-06-01'),
(7, 104, 'PC Peripherals', 8000, '2025-06-05'),
(8, 102, 'Apparel', 7800, '2025-06-08'),
(9, 103, 'PC Peripherals', 320000, '2025-06-10'),
(10, 105, 'Books', 2900, '2025-06-15'),
(11, 101, 'Books', 3500, '2025-06-20'),
(12, 103, 'Appliances', 35000, '2025-06-22');

This data includes customers who have made multiple purchases (e.g., customer IDs 101, 102, 103) and categories purchased by multiple customers (e.g., PC Peripherals, Books), making it ideal for observing the behavior of GROUP BY and HAVING.


The Core Concept: The Definitive Difference Between WHERE and HAVING

The biggest key to understanding the HAVING clause is to clearly grasp its division of labor with the WHERE clause. While both are used for "filtering," the timing and target of their filtering are completely different.

Let's illustrate this difference using the analogy of a restaurant kitchen.

SQL's Order of Operations: A Restaurant Analogy

  1. Table Joins (FROM, JOIN): First, all the ingredients (data) are gathered in the kitchen.
  2. The WHERE Clause: The Chef arrives. Before starting to cook, the chef inspects the individual ingredients and discards any that don't meet the criteria, saying, "I can't use this fish for today's carpaccio."
  3. The GROUP BY Clause: The chef uses the approved ingredients to create various dishes (groups), such as salads, pastas, and meat courses.
  4. The HAVING Clause: The Food Critic arrives. With the finished dishes (groups) laid out on the table, the critic decides which ones to evaluate, saying, "I will only taste dishes that cost more than $20."

As this analogy shows, the two most important takeaways are:

Because of this, conditions using aggregate functions like COUNT() or SUM() only make sense for groups and can therefore only be used in the HAVING clause. You cannot write something like WHERE COUNT(*) > 10.


In Practice: Filtering Aggregated Results with the HAVING Clause

Now, let's see the power of the HAVING clause in action with actual code. First, we'll use GROUP BY to aggregate the "number of orders per customer." This will be our base result before applying the HAVING clause.

-- First, without HAVING, let's count orders per customer
SELECT
  customer_id,
  COUNT(order_id) AS order_count
FROM
  orders
GROUP BY
  customer_id;

Result:

customer_id | order_count
------------|-------------
101         | 4
102         | 3
103         | 3
104         | 1
105         | 1

From this result, suppose we want to "extract only our top customers who have 3 or more orders." This is where the HAVING clause comes in. We apply a condition to the aggregated result, `order_count` (which is COUNT(order_id)).

-- 【HAVING + COUNT】Filter for customers with 3 or more orders
SELECT
  customer_id,
  COUNT(order_id) AS order_count
FROM
  orders
GROUP BY
  customer_id
HAVING
  COUNT(order_id) >= 3;

Result:

customer_id | order_count
------------|-------------
101         | 4
102         | 3
103         | 3

Perfect! Only the customers with 3 or more orders (HAVING COUNT(order_id) >= 3) were filtered. This is the fundamental use of the HAVING clause.


Advanced Examples: Conditions with `SUM` and `AVG`

The HAVING clause isn't limited to COUNT. It can, of course, be combined with other aggregate functions like SUM (total) and AVG (average). This enables more sophisticated data analysis.

【HAVING + SUM】Filtering categories with total sales over 100,000

To find out "which categories contribute most to our revenue," let's calculate the total sales per category and then view only those categories exceeding 100,000.

-- 【HAVING + SUM】Filter for categories with total sales over 100,000
SELECT
  product_category,
  SUM(price) AS total_sales
FROM
  orders
GROUP BY
  product_category
HAVING
  SUM(price) > 100000;

Result:

product_category | total_sales
------------------|-------------
PC Peripherals   | 593000
Appliances       | 243000

This result makes it clear that "PC Peripherals" and "Appliances" are the primary revenue drivers.


【HAVING + AVG】Filtering categories with an average price over 50,000

Next, to investigate "which categories are selling high-ticket items," let's calculate the average price per category and extract only those where the average exceeds 50,000.

-- 【HAVING + AVG】Filter for categories with an average price over 50,000
SELECT
  product_category,
  AVG(price) AS average_price
FROM
  orders
GROUP BY
  product_category
HAVING
  AVG(price) > 50000;

Result:

product_category | average_price
------------------|---------------
PC Peripherals   | 148250
Appliances       | 81000

As you can see, we can evaluate categories from different perspectives, not just total sales but also average price.


The Ultimate Combination: Using WHERE and HAVING Together

WHERE and HAVING are not adversaries; they are partners that work together to achieve more complex filtering. Using both in a single query allows for incredibly powerful analysis.

Our analytical question: "Looking only at orders from June 2025 onwards, who are the customers whose total purchase amount exceeds 100,000?"

If we break down this request, we see there are two filtering steps:

  1. Pre-grouping filter: Limit the records to orders on or after June 1, 2025. → This is a condition on individual records, so it's a job for the WHERE clause.
  2. Post-grouping filter: Limit the groups to customers whose aggregated total purchase amount is greater than 100,000. → This is a condition on the groups, so it's a job for the HAVING clause.

Here is the corresponding SQL:

-- 【WHERE + HAVING】Customers with total purchases > 100k in June onwards
SELECT
  customer_id,
  SUM(price) AS total_spent_in_june_onwards
FROM
  orders
WHERE
  order_date >= '2025-06-01'
GROUP BY
  customer_id
HAVING
  SUM(price) > 100000;

Result:

customer_id | total_spent_in_june_onwards
------------|-----------------------------
101         | 123500
103         | 355000

The processing flow of this query perfectly matches our restaurant analogy:

  1. First, WHERE order_date >= '2025-06-01' selects only the order records from June onwards.
  2. Next, these selected records are grouped by customer using GROUP BY customer_id.
  3. Finally, HAVING SUM(price) > 100000 filters these groups, keeping only those where the total purchase amount exceeds 100,000.

This collaboration between WHERE and HAVING is arguably where SQL shines brightest in data analysis.


【Interactive Playground】SQL Fiddle: Try the HAVING Clause in Your Browser!

Now it's time to turn knowledge into a solid skill! With the "Online SQL Environment" below, you can freely experiment with SQL right in your browser. The orders table from this article is already loaded for you.

Try out different conditions. Simply changing numbers, or swapping >= with <, will show you how the results change and will dramatically deepen your understanding.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SQL HAVING Clause Online Playground</title>
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.7; color: #333; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
    h1 { color: #2c3e50; }
    textarea { width: 100%; height: 200px; font-family: "SF Mono", "Consolas", monospace; font-size: 16px; padding: 12px; border: 1px solid #ccc; border-radius: 6px; box-sizing: border-box; margin-bottom: 1rem; }
    button { background-color: #8e44ad; color: white; border: none; padding: 12px 22px; font-size: 16px; border-radius: 6px; cursor: pointer; transition: background-color 0.2s; }
    button:hover { background-color: #70368b; }
    button:disabled { background-color: #bdc3c7; cursor: not-allowed; }
    #result-container { margin-top: 2rem; border: 1px solid #ddd; padding: 1rem; border-radius: 6px; background: #fdfdfd; min-height: 50px; overflow-x: auto;}
    #error-message { color: #e74c3c; font-weight: bold; }
    table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
    th, td { border: 1px solid #ddd; padding: 10px; text-align: left; white-space: nowrap; }
    th { background-color: #f2f2f2; }
    tr:nth-child(even) { background-color: #f9f9f9; }
  </style>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/sql-wasm.js"></script>
</head>
<body>

  <h1>Try Out SQL!</h1>
  <p>Enter your SQL query in the textarea below and click the "Execute" button. Feel free to try all the different queries from the article!</p>

  <textarea id="sql-input">-- Feel free to experiment!
-- Example: Filter for the 'PC Peripherals' category (WHERE),
--          then calculate total spending per customer (GROUP BY),
--          and finally, show only customers who spent over 200,000 (HAVING).
SELECT
  customer_id,
  SUM(price) AS total_spent_on_pc
FROM
  orders
WHERE
  product_category = 'PC Peripherals'
GROUP BY
  customer_id
HAVING
  SUM(price) > 200000;</textarea>
  
  <button id="execute-btn">Execute</button>
  
  <div id="result-container">
    <p id="error-message"></p>
    <div id="result-output"></div>
  </div>

  <script>
    const sqlInput = document.getElementById('sql-input');
    const executeBtn = document.getElementById('execute-btn');
    const errorMsg = document.getElementById('error-message');
    const resultOutput = document.getElementById('result-output');

    let db;

    async function initDb() {
      executeBtn.disabled = true;
      executeBtn.textContent = 'DB Initializing...';
      try {
        const SQL = await initSqlJs({
          locateFile: file => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/${file}`
        });
        db = new SQL.Database();
        
        const setupSql = `
          DROP TABLE IF EXISTS orders;
          CREATE TABLE orders (
            order_id INTEGER PRIMARY KEY,
            customer_id INTEGER NOT NULL,
            product_category TEXT NOT NULL,
            price INTEGER NOT NULL,
            order_date DATE NOT NULL
          );
          INSERT INTO orders (order_id, customer_id, product_category, price, order_date) VALUES
          (1, 101, 'PC Peripherals', 15000, '2025-05-10'), (2, 102, 'Books', 3200, '2025-05-11'),
          (3, 101, 'PC Peripherals', 250000, '2025-05-12'), (4, 103, 'Appliances', 88000, '2025-05-15'),
          (5, 102, 'Books', 4500, '2025-05-20'), (6, 101, 'Appliances', 120000, '2025-06-01'),
          (7, 104, 'PC Peripherals', 8000, '2025-06-05'), (8, 102, 'Apparel', 7800, '2025-06-08'),
          (9, 103, 'PC Peripherals', 320000, '2025-06-10'), (10, 105, 'Books', 2900, '2025-06-15'),
          (11, 101, 'Books', 3500, '2025-06-20'), (12, 103, 'Appliances', 35000, '2025-06-22');
        `;
        db.run(setupSql);
        
        executeBtn.disabled = false;
        executeBtn.textContent = 'Execute';
        resultOutput.innerHTML = '<p>Ready to go! Feel free to experiment with your own SQL queries.</p>';

      } catch (err) {
        errorMsg.textContent = 'Failed to initialize database: ' + err.message;
        console.error(err);
      }
    }

    function executeSql() {
      if (!db) return;
      const sql = sqlInput.value;
      errorMsg.textContent = '';
      resultOutput.innerHTML = '';
      try {
        const results = db.exec(sql);
        if (results.length === 0) {
          resultOutput.innerHTML = '<p>Query executed successfully, but no result set was returned (e.g., for INSERT, UPDATE, etc.).</p>';
          return;
        }
        results.forEach(result => {
          const table = document.createElement('table');
          const thead = document.createElement('thead');
          const tbody = document.createElement('tbody');
          const headerRow = document.createElement('tr');
          result.columns.forEach(colName => {
            const th = document.createElement('th');
            th.textContent = colName;
            headerRow.appendChild(th);
          });
          thead.appendChild(headerRow);
          result.values.forEach(row => {
            const bodyRow = document.createElement('tr');
            row.forEach(cellValue => {
              const td = document.createElement('td');
              td.textContent = cellValue === null ? 'NULL' : (typeof cellValue === 'number' ? cellValue.toLocaleString() : cellValue);
              bodyRow.appendChild(td);
            });
            tbody.appendChild(bodyRow);
          });
          table.appendChild(thead);
          table.appendChild(tbody);
          resultOutput.appendChild(table);
        });
      } catch (err) {
        errorMsg.textContent = 'SQL Error: ' + err.message;
        console.error(err);
      }
    }
    executeBtn.addEventListener('click', executeSql);
    initDb();
  </script>
</body>
</html>

Inside the SQL Engine: The Logical Order of Query Execution

To further deepen your understanding of the difference between WHERE and HAVING, it's incredibly helpful to know the "logical processing order" of how SQL handles a query internally. The order in which we write our code (SELECT, FROM, WHERE...) is different from the order in which SQL interprets and executes it.

SQL's Logical Execution Order:

  1. FROM: First, determines which table to retrieve data from.
  2. WHERE: Next, filters individual rows based on a condition.
  3. GROUP BY: Bundles the filtered rows into groups.
  4. HAVING: Filters the grouped results based on a condition.
  5. SELECT: Finally, decides which columns to display.
  6. ORDER BY: Sorts the result set in a specified order.
  7. LIMIT: Restricts the number of rows to display.

Looking at this order, you can clearly see that WHERE comes before GROUP BY, and HAVING comes right after. This is the core reason why aggregate functions can't be used in WHERE but can be used in HAVING.

This order also explains why you generally can't use an alias defined in the SELECT clause (e.g., SUM(price) AS total_sales) in the WHERE or HAVING clauses (with some database exceptions). It's because the SELECT clause is evaluated later. (You can often use aliases in the ORDER BY clause, because ORDER BY is evaluated after SELECT.)


Conclusion: Master the HAVING Clause and Take Your Data Analysis to the Next Level

In this deep dive, we explored the powerful HAVING clause, a tool for filtering the results of a GROUP BY aggregation.

Let's review the key points one last time:

The HAVING clause might seem like a minor feature at first glance. However, whether you can master it or not will make a world of difference in the quality and depth of the insights you can extract from your data. It enables you to answer sharper, more business-critical questions with SQL, such as, "Among our best-selling categories, which ones are the most profitable?" or "Within our active user base, what is the profile of our top-spending users?"

Please, play around extensively in the online playground from this article and get comfortable with the HAVING clause. I guarantee that your data utilization skills as a web creator will improve by leaps and bounds.