WHERE vs HAVING in SQL: Execution Order & Key Differences
Master WHERE vs HAVING in SQL. Learn why WHERE filters before GROUP BY, why aggregate functions fail in WHERE, and the full 7-step query execution order.
Ask any beginner to calculate total revenue, and they will write a simple SUM(total_amount). But real businesses are not managed with a single grand total. Leaders need revenue broken down per status, per payment method, per region, and per month.
That breakdown requires GROUP BY. And the moment you need to filter those summarized groups—such as finding payment methods with over ₹150,000 in volume—you face the most tested query execution trap in technical interviews: WHERE vs HAVING.
In this guide, we break down GROUP BY mechanics, dissect the exact 7-step logical query execution order that governs all SQL databases, and demonstrate why aliases fail in WHERE clauses. Every query in this guide runs against a live PostgreSQL e-commerce dataset with 2,000 orders and 500 customers, with all metrics verified. For related database foundations, explore our SQL NULL Guide and SQL CTE Guide.
1. GROUP BY Mechanics: Collapsing Rows into Dimensional Summaries
An aggregate function (SUM, AVG, COUNT, MIN, MAX) without a GROUP BY folds the entire table into a single summary row:
SELECT ROUND(SUM(total_amount), 2) AS grand_total
FROM orders;Result: 782,905.04
To split that total across distinct dimensions, use GROUP BY. The rule of thumb for standard SQL queries: any non-aggregated column in your SELECT list must appear in your GROUP BY clause.
Revenue and Volume by Order Status
Let's group the 2,000 orders in our database by their status:
SELECT status,
COUNT(*) AS orders,
ROUND(SUM(total_amount), 2) AS revenue,
ROUND(AVG(total_amount), 2) AS avg_order
FROM orders
GROUP BY status
ORDER BY revenue DESC;The Output
| status | orders | revenue | avg_order |
|---|---|---|---|
| completed | 1,101 | ₹535,329.63 | ₹486.22 |
| cancelled | 284 | ₹142,321.95 | ₹501.13 |
| pending | 223 | ₹116,879.58 | ₹524.12 |
| returned | 192 | ₹87,975.97 | ₹458.21 |
| refunded | 200 | -₹99,602.09 | -₹498.01 |
What the Numbers Reveal
- One summary row per distinct group: The 2,000 raw rows collapse into exactly 5 status categories.
- Hidden financial realities: The grand total of ₹782,905.04 hid the fact that ₹99,602.09 was paid out in refunds. Grouping exposes the underlying distribution.
- Refund averages are negative: In our e-commerce schema, refunds carry negative values (
-₹498.01average), which directly dragged down the overall table average.
2. Grouping by Payment Method
Swapping the dimension column lets you answer a new business question instantly. Let's analyze customer payment preferences:
SELECT payment_method,
COUNT(*) AS orders,
ROUND(SUM(total_amount), 2) AS revenue
FROM orders
GROUP BY payment_method
ORDER BY revenue DESC;The Output
| payment_method | orders | revenue |
|---|---|---|
| credit_card | 446 | ₹173,019.72 |
| bank_transfer | 398 | ₹157,213.44 |
| crypto | 385 | ₹156,611.59 |
| paypal | 391 | ₹153,070.22 |
| debit_card | 380 | ₹142,990.07 |
Credit card leads transaction volume at ₹173,019.72, while debit card trails at ₹142,990.07. Notice that these totals reflect net volume across all order statuses.
3. The Core Trap: Filtering Groups with HAVING vs WHERE
Suppose your stakeholder asks: "Show me only the payment methods that generated more than ₹150,000 in revenue."
Natural intuition leads most beginners to write a WHERE filter:
-- ❌ THIS CODE THROWS A RUNTIME ERROR
SELECT payment_method,
ROUND(SUM(total_amount), 2) AS revenue
FROM orders
WHERE SUM(total_amount) > 150000
GROUP BY payment_method;The Database Error
ERROR: aggregate functions are not allowed in WHERE
LINE 4: WHERE SUM(total_amount) > 150000
The database halts immediately. Why? Because the WHERE clause evaluates individual rows before any grouping or aggregation takes place. At the time WHERE executes, SUM(total_amount) does not exist yet.
The Correct Query: Using HAVING
To filter summarized groups based on aggregate conditions, you must use HAVING:
-- ✅ THE CORRECT WAY: HAVING filters groups
SELECT payment_method,
ROUND(SUM(total_amount), 2) AS revenue
FROM orders
GROUP BY payment_method
HAVING SUM(total_amount) > 150000
ORDER BY revenue DESC;The Filtered Result
| payment_method | revenue |
|---|---|
| credit_card | ₹173,019.72 |
| bank_transfer | ₹157,213.44 |
| crypto | ₹156,611.59 |
| paypal | ₹153,070.22 |
Exactly 4 rows return. debit_card (₹142,990.07) was below the ₹150,000 threshold and was cleanly discarded after aggregation.
The Golden Rule of Filtering
WHERE filters individual rows before grouping occurs.
HAVING filters entire groups after aggregation completes.
Never put an aggregate function (SUM, AVG, COUNT, MIN, MAX) inside WHERE.
4. Query Execution Order: Why the Difference Exists
SQL syntax is declarative: you write clauses in one order, but the query optimizer executes them in a completely different sequence. Understanding this 7-step sequence makes database errors predictable and intuitive.
┌──────────────────────────────────────────────────────────┐
│ LOGICAL QUERY EXECUTION ORDER │
├──────────────────────────────────────────────────────────┤
│ 1. FROM & JOINS → Retrieve and combine base tables │
│ 2. WHERE → Filter individual rows │
│ 3. GROUP BY → Collapse rows into distinct groups │
│ 4. HAVING → Filter the aggregated groups │
│ 5. SELECT → Compute columns, expressions, aliases│
│ 6. DISTINCT → Deduplicate result rows │
│ 7. ORDER BY → Sort the final rows │
│ 8. LIMIT / OFFSET → Truncate to the requested window │
└──────────────────────────────────────────────────────────┘
Why Aliases Fail in WHERE and GROUP BY
Look at where SELECT sits in the pipeline (Step 5). When you write:
SELECT payment_method,
SUM(total_amount) AS total_rev -- alias created at Step 5
FROM orders -- Step 1
WHERE total_rev > 150000 -- ❌ Step 2: total_rev does NOT exist yet!
GROUP BY payment_method; -- Step 3Because WHERE runs at Step 2 and GROUP BY runs at Step 3, the alias total_rev defined in SELECT (Step 5) has not been compiled.
However, ORDER BY executes at Step 7 (after SELECT). Therefore, sorting by an alias works seamlessly:
ORDER BY revenue DESC -- ✅ Step 7: revenue alias is fully available5. Combining WHERE and HAVING in Production Queries
In professional data analysis, you almost always use WHERE and HAVING together in the same query:
- Use
WHEREto strip out irrelevant rows early (reducing memory usage and compute cost). - Use
GROUP BYto summarize the remaining rows. - Use
HAVINGto discard groups that do not meet your business threshold.
Example: High-Performing Payment Methods for Completed Orders Only
SELECT payment_method,
COUNT(*) AS completed_orders,
ROUND(SUM(total_amount), 2) AS clean_revenue
FROM orders
WHERE status = 'completed' -- Step 2: filter out refunds & cancellations first
GROUP BY payment_method -- Step 3: group remaining rows
HAVING SUM(total_amount) > 100000 -- Step 4: keep payment methods with >₹100k completed
ORDER BY clean_revenue DESC; -- Step 7: sort outputThe Result
| payment_method | completed_orders | clean_revenue |
|---|---|---|
| credit_card | 248 | ₹120,412.30 |
| bank_transfer | 224 | ₹113,156.64 |
| paypal | 219 | ₹106,892.15 |
| crypto | 212 | ₹102,448.20 |
By filtering WHERE status = 'completed' upfront, we eliminated the negative refund balances, ensuring clean_revenue represents true realized earnings.
6. Aggregates and NULLs: Three Flavors of COUNT
A major source of aggregation confusion in technical assessments is how SQL aggregates handle NULL values. Let's observe the behavior across our 2,000-order table and 800-row reviews table.
SELECT COUNT(*) AS all_rows,
COUNT(shipped_date) AS has_ship_date,
COUNT(DISTINCT customer_id) AS unique_customers
FROM orders;The Three COUNTs
| all_rows | has_ship_date | unique_customers |
|---|---|---|
| 2,000 | 1,198 | 544 |
COUNT(*)(2,000): Counts all rows, regardless of whether any column containsNULL.COUNT(column)(1,198): Counts only rows whereshipped_dateis NOT NULL (meaning 802 orders have not yet shipped or were cancelled).COUNT(DISTINCT column)(544): Counts distinct non-NULL customer IDs. (Note: Our dataset contains 500 customers in the customer table, exposing 44 orphaned customer IDs inorders).
The Average Rating Trap
In our reviews table (800 rows), 86 reviews have a NULL rating:
SELECT COUNT(*) AS all_reviews,
COUNT(rating) AS rated_reviews,
ROUND(AVG(rating), 3) AS avg_real,
ROUND(AVG(COALESCE(rating, 0)), 3) AS avg_zeroed
FROM reviews;The Output
| all_reviews | rated_reviews | avg_real | avg_zeroed |
|---|---|---|---|
| 800 | 714 | 2.941 | 2.625 |
AVG(rating) skips the 86 NULL rows and calculates the average over the 714 rated reviews (2.941). If you mistakenly replace NULL with 0 using COALESCE, you invent 86 fake 0-star ratings and depress your true customer rating down to 2.625.
7. WHERE vs HAVING: Feature Comparison
| Feature / Criteria |
|---|
8. Summary Checklist & Practice
When writing aggregation queries, follow this mental checklist:
- Grain check: What does one row represent in your output? Ensure every unaggregated column in
SELECTappears inGROUP BY. - Filter placement: If your condition evaluates row-level fields (e.g.
status = 'completed',order_date >= '2024-01-01'), place it inWHERE. If it evaluates a calculated summary (e.g.SUM(...) > 50000,COUNT(*) >= 5), place it inHAVING. - Execution order awareness: Remember why aliases fail in
WHEREandGROUP BY, and keepORDER BYfor final sorting.
To master SQL aggregations, test execution order traps, and build portfolio-grade skills, practice live queries in our Interactive SQL Practice Sandbox or enroll in the complete Data Analyst Career Track. For foundational tutorials, read our SQL JOIN Fan-Out Guide and SQL Date Functions Guide.
Master SQL Aggregations & Execution Order
Drill WHERE, GROUP BY, and HAVING problems in our interactive PostgreSQL sandbox, or enroll in the complete Data Analyst Career Track.
Practice Aggregations FreeFrequently Asked Questions
What is the difference between WHERE and HAVING in SQL?
WHERE filters individual rows before grouping occurs, and cannot evaluate aggregate functions like SUM() or COUNT(). HAVING filters entire groups after aggregation has completed, evaluating conditions on summarized metrics.
Why does WHERE SUM(total_amount) > 150000 throw an error?
In SQL's logical execution order, WHERE runs at step 2, while GROUP BY runs at step 3. At step 2, groups do not yet exist and aggregate functions have not been calculated. SQL engines reject aggregate functions in WHERE with 'ERROR: aggregate functions are not allowed in WHERE'.
What is the exact execution order of a SQL query?
SQL queries execute in this logical sequence: 1. FROM (and JOINs), 2. WHERE (filter rows), 3. GROUP BY (form groups), 4. HAVING (filter groups), 5. SELECT (compute expressions and aliases), 6. DISTINCT, 7. ORDER BY (sort output), 8. LIMIT / OFFSET.
Why can't I use a column alias created in SELECT inside WHERE or GROUP BY?
SELECT executes at step 5, after WHERE (step 2) and GROUP BY (step 3). Column aliases defined in SELECT do not exist yet when WHERE or GROUP BY evaluate. However, ORDER BY runs at step 7 (after SELECT), so it can reference SELECT aliases.
How do aggregates handle NULL values in GROUP BY queries?
Aggregate functions like SUM, AVG, MIN, and MAX ignore NULL values automatically. COUNT(*) counts all rows including NULLs. When grouping by a nullable column, all NULL rows are gathered into a single distinct group.

Written by
Founder at Topfolio with 6+ years in data & analytics across JPMC, Ultrahuman, and high-growth startups. Sat on hiring panels, reviewed 500+ resumes, and writes practical SQL & data guides.
Related Articles
SQL CTE (WITH Clause): Syntax, Chaining, Recursive CTEs & Real Examples (2026)
Master SQL CTEs (Common Table Expressions) using the WITH clause. Learn exact syntax, execution lifecycle, chaining CTEs, recursive CTEs for org hierarchies, and CTEs vs subqueries vs temp tables.
DDL SQL Commands: Complete Guide to Data Definition Language
Master DDL SQL commands: CREATE, ALTER, DROP, TRUNCATE, and RENAME with practical syntax, schema constraints, and DDL vs DML comparisons.
Delete Duplicate Records in SQL: 3 Proven Methods with Examples
Learn how to delete duplicate records in SQL using ROW_NUMBER() CTEs, self-joins with MIN/MAX IDs, and safe transaction workflows across dialects.