HAVING vs WHERE: Filtering Aggregated Data Without Confusion

A WHERE clause filters rows before grouping. A HAVING clause filters groups after aggregation. Most errors come from forgetting this ordering, usually by putting SUM() or COUNT() inside a WHERE clause and getting a syntax error. This guide explains the execution order that decides which clause you need, then shows SQL, pandas, and PySpark implementations you can copy.

Quick Takeaways

  • WHERE filters individual rows before GROUP BY. It cannot reference aggregate functions.
  • HAVING filters groups after aggregation. It can reference SUM(), AVG(), COUNT(), MIN(), and MAX().
  • Put non-aggregate conditions in WHERE. This shrinks the dataset early and cuts compute cost.
  • In pandas, WHERE maps to boolean masking or .query(), and HAVING maps to .groupby().filter() or a post-aggregation mask.
Feature WHERE HAVING
Filters Individual rows Aggregated groups
Runs Before GROUP BY After GROUP BY
Aggregate functions allowed No Yes
Works without GROUP BY Yes Yes (whole table = one group)
Index usage Can use indexes Rarely
Typical cost Low (reduces rows early) Higher (computes groups first)

Why the Two Clauses Exist: SQL Logical Execution Order

SQL does not run in the order you write it. The engine evaluates clauses in a fixed logical order:

Step Clause What happens
1 FROM / JOIN Build the working dataset
2 WHERE Discard rows that fail the row-level predicate
3 GROUP BY Partition surviving rows into groups
4 HAVING Discard groups that fail the aggregate predicate
5 SELECT Compute output columns and aliases
6 ORDER BY Sort the result
7 LIMIT / OFFSET Trim the output

When WHERE runs (step 2), no groups exist yet, so there is no group total to compare against. HAVING runs at step 4, after the engine has computed the aggregates. That ordering is the whole difference.

This order also explains a second rule. Because SELECT runs after HAVING, column aliases defined in SELECT are not guaranteed to be visible in HAVING. More on that below.

Sample Dataset

The examples below use a small orders table.

-- Create a sample table for the examples
CREATE TABLE orders (
    order_id     INT PRIMARY KEY,
    customer_id  INT,
    region       VARCHAR(20),
    status       VARCHAR(20),
    amount       DECIMAL(10, 2),
    order_date   DATE
);

INSERT INTO orders VALUES
(1, 101, 'EMEA', 'completed', 1200.00, '2026-01-05'),
(2, 101, 'EMEA', 'completed',  800.00, '2026-02-11'),
(3, 102, 'APAC', 'refunded',   450.00, '2026-02-14'),
(4, 103, 'AMER', 'completed', 3000.00, '2026-03-02'),
(5, 103, 'AMER', 'completed', 2500.00, '2026-03-19'),
(6, 104, 'APAC', 'completed',  300.00, '2026-04-07'),
(7, 105, 'EMEA', 'cancelled',  150.00, '2026-04-21'),
(8, 105, 'EMEA', 'completed',  950.00, '2026-05-01');

WHERE: Row-Level Filtering

Use WHERE when the condition applies to a single row and does not depend on any group.

-- Total completed revenue per region
SELECT
    region,
    SUM(amount) AS total_revenue   -- aggregate runs on rows that passed WHERE
FROM orders
WHERE status = 'completed'         -- row filter: cancelled/refunded rows never reach GROUP BY
GROUP BY region;

The engine drops the refunded and cancelled rows first. Then it groups and sums only the survivors.

The Classic Error

Beginners often try this:

-- This FAILS: aggregate functions are not allowed in WHERE
SELECT region, SUM(amount) AS total_revenue
FROM orders
WHERE SUM(amount) > 3000
GROUP BY region;

PostgreSQL returns aggregate functions are not allowed in WHERE. The other major engines raise equivalent errors. The group total does not exist at step 2, so the predicate cannot be evaluated.

HAVING: Group-Level Filtering

Use HAVING when the condition depends on an aggregate.

-- Regions whose completed revenue exceeds 3,000
SELECT
    region,
    SUM(amount) AS total_revenue
FROM orders
WHERE status = 'completed'         -- step 1: filter rows
GROUP BY region                    -- step 2: build groups
HAVING SUM(amount) > 3000;         -- step 3: filter groups by their aggregate

Result:

region total_revenue
AMER 5500.00
EMEA 2950.00

Wait: EMEA’s total is 2,950, which fails the > 3000 test, so only AMER survives. Always verify aggregate math by running the query. A boundary mistake like this is easy to miss in prose.

Combining WHERE and HAVING

Both clauses can appear in one query. Each does its own job.

-- Customers with at least 2 completed orders worth more than 2,000 in total
SELECT
    customer_id,
    COUNT(*)    AS order_count,
    SUM(amount) AS total_spent
FROM orders
WHERE status = 'completed'                  -- row-level
GROUP BY customer_id
HAVING COUNT(*) >= 2                        -- group-level
   AND SUM(amount) > 2000;                  -- group-level

Common Pitfalls and Edge Cases

1. Non-Aggregate Conditions in HAVING

This query is legal but wasteful:

-- Works, but groups every status before discarding most of them
SELECT region, SUM(amount)
FROM orders
GROUP BY region, status
HAVING status = 'completed';

The engine may aggregate all statuses before discarding groups. Many optimizers push the predicate down automatically, but you should not depend on that. Move the condition to WHERE:

SELECT region, SUM(amount)
FROM orders
WHERE status = 'completed'
GROUP BY region;

2. Column Aliases in HAVING

Alias support differs by engine because SELECT is evaluated after HAVING in the logical order.

Engine Alias in HAVING Alias in WHERE
PostgreSQL No No
SQL Server No No
Oracle No No
MySQL / MariaDB Yes (extension) No
SQLite Yes No

For portable code, repeat the aggregate expression in HAVING, or wrap the query in a subquery or CTE.

-- Portable alias filtering with a CTE
WITH region_totals AS (
    SELECT region, SUM(amount) AS total_revenue
    FROM orders
    WHERE status = 'completed'
    GROUP BY region
)
SELECT *
FROM region_totals
WHERE total_revenue > 3000;   -- plain WHERE now works because total_revenue is a real column

3. HAVING Without GROUP BY

If you use HAVING with no GROUP BY, the engine treats the whole table as a single group.

-- Returns a row only if the table has more than 5 completed orders
SELECT COUNT(*) AS completed_orders
FROM orders
WHERE status = 'completed'
HAVING COUNT(*) > 5;

4. NULL Handling

COUNT(*) counts rows. COUNT(column) skips NULLs. A HAVING COUNT(amount) > 2 test can behave differently from HAVING COUNT(*) > 2 when amount has missing values. Pick the one that matches your intent.

5. Modern Alternative: QUALIFY

Snowflake, BigQuery, and Databricks support QUALIFY, which filters on window function results. It is to window functions what HAVING is to aggregates.

-- Keep each customer's most recent order (Snowflake / BigQuery syntax)
SELECT *
FROM orders
QUALIFY ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) = 1;

Performance: Why the Placement Matters

Filtering early reduces the rows that flow into the expensive grouping step.

Strategy Rows entering GROUP BY Memory / shuffle cost Index usable
Condition in WHERE Only matching rows Low Yes
Same condition in HAVING Potentially all rows Higher Usually no
Aggregate condition in HAVING Required Unavoidable No

Two practical rules:

  • Rule 1: If a condition can run without knowing the group, put it in WHERE.
  • Rule 2: Reserve HAVING for conditions that need an aggregate.

On distributed engines such as Spark or BigQuery, early filtering also reduces the data moved across the network during the shuffle that precedes aggregation.

The Same Logic in pandas

Analysts who move between SQL and Python need the mapping. In pandas, the order of operations is explicit because you write each step in sequence.

import pandas as pd

# Rebuild the sample dataset
df = pd.DataFrame({
    "order_id":    [1, 2, 3, 4, 5, 6, 7, 8],
    "customer_id": [101, 101, 102, 103, 103, 104, 105, 105],
    "region":      ["EMEA", "EMEA", "APAC", "AMER", "AMER", "APAC", "EMEA", "EMEA"],
    "status":      ["completed", "completed", "refunded", "completed",
                    "completed", "completed", "cancelled", "completed"],
    "amount":      [1200, 800, 450, 3000, 2500, 300, 150, 950],
})

# WHERE + GROUP BY + HAVING equivalent
result = (
    df[df["status"] == "completed"]                      # WHERE: boolean mask on rows
      .groupby("region", as_index=False)                 # GROUP BY
      .agg(total_revenue=("amount", "sum"))              # aggregate with named output
      .query("total_revenue > 3000")                     # HAVING: filter on aggregated column
)
print(result)

When you want to keep the original rows that belong to qualifying groups, use GroupBy.filter():

# Keep all completed rows for customers with at least 2 completed orders
qualified = (
    df[df["status"] == "completed"]
      .groupby("customer_id")
      .filter(lambda g: len(g) >= 2)   # group-level predicate, returns original rows
)

groupby().agg() collapses groups to one row each. groupby().filter() preserves row granularity. SQL’s HAVING collapses by default, so use a window function or join back to the original table if you need row-level output.

The Same Logic in PySpark

from pyspark.sql import SparkSession, functions as F

spark = SparkSession.builder.appName("having_vs_where").getOrCreate()

orders = spark.table("orders")

result = (
    orders
    .filter(F.col("status") == "completed")                    # WHERE
    .groupBy("region")                                         # GROUP BY
    .agg(F.sum("amount").alias("total_revenue"))               # aggregation
    .filter(F.col("total_revenue") > 3000)                     # HAVING (alias is a real column now)
)

result.show()

In the DataFrame API, filter() is used for both. Its position relative to groupBy().agg() determines whether it behaves like WHERE or HAVING. Spark’s Catalyst optimizer also pushes eligible row-level predicates down automatically.

Real-World Use Cases

Customer Churn Risk: Repeated Payment Failures

Flag customers with three or more failed payments in the last 90 days.

SELECT customer_id, COUNT(*) AS failed_payments
FROM payments
WHERE status = 'failed'                                  -- row filter
  AND payment_date >= CURRENT_DATE - INTERVAL '90 days'  -- row filter
GROUP BY customer_id
HAVING COUNT(*) >= 3;                                    -- group filter

The two row-level predicates belong in WHERE. The threshold on the count belongs in HAVING.

Fraud Detection: Burst Transactions

Find cards with more than five transactions in a single hour.

SELECT card_id,
       DATE_TRUNC('hour', txn_time) AS txn_hour,
       COUNT(*)                     AS txn_count,
       SUM(amount)                  AS hourly_spend
FROM transactions
WHERE txn_time >= CURRENT_DATE
GROUP BY card_id, DATE_TRUNC('hour', txn_time)
HAVING COUNT(*) > 5;

IoT Sensor Streams: Anomalous Devices

Identify devices whose average temperature in the last day exceeds a safe limit, ignoring readings flagged as invalid.

SELECT device_id, AVG(temperature) AS avg_temp
FROM sensor_readings
WHERE reading_valid = TRUE                       -- drop bad readings before averaging
  AND reading_time >= NOW() - INTERVAL '1 day'
GROUP BY device_id
HAVING AVG(temperature) > 85;

Filtering invalid readings in HAVING would be wrong here. The bad values would already have skewed the average.

Retail: Category Performance Review

Find product categories with at least 50 orders and average order value above 40.

SELECT category,
       COUNT(*)    AS orders,
       AVG(amount) AS avg_order_value
FROM sales
WHERE sale_date BETWEEN '2026-01-01' AND '2026-09-30'
GROUP BY category
HAVING COUNT(*) >= 50
   AND AVG(amount) > 40;

Decision Checklist

Ask one question for each condition: “Can I evaluate this on a single row, without knowing anything about its group?”

Answer Clause
Yes WHERE
No, it needs SUM/COUNT/AVG/MIN/MAX HAVING
It needs a window function result QUALIFY or a subquery

FAQ

Can I use WHERE and HAVING in the same query?

Yes. WHERE filters rows first, then GROUP BY forms groups, then HAVING filters those groups. Most production reporting queries use both.

Why does SQL not allow aggregate functions in WHERE?

WHERE executes before GROUP BY, so group totals do not exist yet. The engine cannot compare a row to a value it has not computed. Move the aggregate condition to HAVING.

Is HAVING slower than WHERE?

Usually, yes, when both could express the filter. WHERE reduces the row count before aggregation and can use indexes. HAVING runs after grouping, so the engine does more work first. Many optimizers rewrite non-aggregate HAVING predicates into WHERE, but you should write them correctly yourself.

Can I use a column alias in HAVING?

It depends on the database. MySQL and SQLite allow it. PostgreSQL, SQL Server, and Oracle do not, because SELECT aliases are created after HAVING runs. For portable code, repeat the aggregate expression or use a CTE.

Hot this week

Cohort Analysis: Understand Retention Over Time

Learn cohort analysis in Python with Pandas. Build retention tables, heatmaps, and churn insights step by step. Copy the code and start analyzing today.

SQL vs Pandas vs Polars: Which Should You Use for Which Task?

Compare SQL, Pandas, and Polars on speed, memory, and scale. Use our task-by-task decision guide and code examples to pick the right tool today.

ETL vs ELT: Understanding Modern Data Pipelines

Compare ETL vs ELT pipelines with code, a decision table, and real use cases. Pick the right architecture for your warehouse—read the full guide now.

SQL Joins Explained: INNER, LEFT, RIGHT, and FULL With Visuals

Learn SQL joins with clear diagrams, sample data, and Python code. Master INNER, LEFT, RIGHT, and FULL JOIN in minutes. Start querying smarter today.

Prompt Patterns for Data Analysts: 20 Prompts That Save Hours

Copy 20 tested prompt patterns for data analysts to clean data, write SQL, debug Pandas, and draft reports faster. Grab the templates now.

Topics

Cohort Analysis: Understand Retention Over Time

Learn cohort analysis in Python with Pandas. Build retention tables, heatmaps, and churn insights step by step. Copy the code and start analyzing today.

SQL vs Pandas vs Polars: Which Should You Use for Which Task?

Compare SQL, Pandas, and Polars on speed, memory, and scale. Use our task-by-task decision guide and code examples to pick the right tool today.

ETL vs ELT: Understanding Modern Data Pipelines

Compare ETL vs ELT pipelines with code, a decision table, and real use cases. Pick the right architecture for your warehouse—read the full guide now.

SQL Joins Explained: INNER, LEFT, RIGHT, and FULL With Visuals

Learn SQL joins with clear diagrams, sample data, and Python code. Master INNER, LEFT, RIGHT, and FULL JOIN in minutes. Start querying smarter today.

Prompt Patterns for Data Analysts: 20 Prompts That Save Hours

Copy 20 tested prompt patterns for data analysts to clean data, write SQL, debug Pandas, and draft reports faster. Grab the templates now.

Aggregate Functions and GROUP BY: Summarizing Data With SQL

Learn SQL aggregate functions and GROUP BY with runnable examples, HAVING, ROLLUP, and pandas equivalents. Copy the queries and start summarizing data.

KPI Visualization: Choosing and Displaying the Right Metrics

Learn how to pick KPIs and chart them correctly. Includes Python code, chart-selection tables, and use cases. Build better dashboards today.

SELECT, WHERE, ORDER BY, and LIMIT: Your First Real Queries

Learn SQL SELECT, WHERE, ORDER BY, and LIMIT with runnable examples, comparison tables, and data science use cases. Write your first query today.

Related Articles

Popular Categories