SQL • AGGREGATION AND GROUPING

Aggregates with WHERE — Use aggregates with WHERE filters appropriately (conceptual)

Understanding how row-level filtering with WHERE shapes the input to aggregate functions in SQL queries.

Historical Context & Motivation

The need to summarize large datasets did not originate with computers — accountants and statisticians have computed totals, averages, and counts for centuries. When E. F. Codd published his relational model in 1970, he formalized the idea that queries could both filter rows and compute summaries over them in a single declarative statement. The challenge of combining row-level predicates (deciding which rows participate) with aggregate functions (collapsing many rows into a single summary value) became one of the most important conceptual hurdles in query language design. Today, nearly every analytical SQL query interleaves WHERE filters with aggregate computations, making this interaction a cornerstone of database fluency.

1970
Codd's Relational Model
E. F. Codd introduces the relational model at IBM, formalizing selection (filtering) and projection as algebraic operations on sets of tuples.
1974
SEQUEL at IBM
Chamberlin and Boyce design SEQUEL (later SQL), incorporating a WHERE clause for row-level predicates and built-in aggregate functions such as SUM, COUNT, and AVG.
1986
SQL-86 Standard
ANSI publishes the first SQL standard, codifying the logical processing order: FROM → WHERE → GROUP BY → HAVING → SELECT, establishing the rule that WHERE precedes aggregation.
1992
SQL-92 Refinements
SQL-92 expands subquery support, enabling aggregate results to be used inside WHERE through scalar subqueries, further clarifying the boundary between filtering and aggregation.
2003+
Window Functions & Modern Analytics
SQL:2003 introduces window functions, offering a third layer of computation that operates after WHERE but without collapsing rows, complementing traditional aggregates.

The central question this lesson addresses is deceptively simple: When you write a WHERE clause alongside an aggregate function, which rows does the aggregate see? Misunderstanding this interaction is responsible for some of the most common SQL bugs — queries that silently return incorrect totals because the filter was placed in the wrong clause, or queries that fail to execute because an aggregate was referenced where only row-level expressions are permitted.

Core Principles & Definitions

Before exploring how WHERE and aggregates cooperate, it is essential to establish a precise vocabulary. SQL's logical processing order is the key to understanding every rule in this lesson: the database engine conceptually evaluates clauses in a fixed sequence, and each clause can only reference information available at its stage in that sequence.

1

Logical Processing Order

SQL evaluates clauses in this conceptual order: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. The WHERE clause executes before any grouping or aggregation occurs.
2

Row-Level vs. Group-Level

WHERE operates on individual rows before they are grouped. HAVING operates on groups after aggregation. Aggregate functions cannot appear directly in a WHERE clause.
3

Aggregate Functions

Functions like COUNT, SUM, AVG, MIN, and MAX consume a set of values and return a single scalar result. They only operate on the rows that survive the WHERE filter.
4

The WHERE–HAVING Boundary

Use WHERE to restrict which rows feed into the aggregate. Use HAVING to restrict which aggregated groups appear in the result. Confusing these two leads to logic errors or syntax errors.
5

NULL Handling

Most aggregates ignore NULL values. A WHERE clause that filters out NULLs has the same effect on COUNT(column) but a different effect on COUNT(*), which counts rows regardless of column nullability.
KEY TAKEAWAY
Think of WHERE as a bouncer at a nightclub and the aggregate function as the DJ counting the crowd inside. The bouncer decides who gets in before the DJ starts counting. You cannot ask the bouncer to reject people based on how many are already inside — that is HAVING's job, applied after the count is known. If you want the DJ to count only VIP guests, you tell the bouncer to admit only VIPs, and the DJ counts whoever made it through the door.

Visual Explanation — SQL Logical Processing Pipeline

The pipeline diagram shows how the five rows from the orders table are reduced to three by the WHERE clause before any aggregate function executes. The two cancelled rows never reach SUM or COUNT.

The diagram above illustrates the most critical insight of this lesson: WHERE acts as a gatekeeper that determines which rows are eligible for aggregation. In the example, the original table contains five rows with a total amount of 2,100. After the WHERE clause filters out the two cancelled orders, the aggregate SUM(amount) computes 1,400 — reflecting only shipped orders. This is not a subtlety; it is the fundamental mechanism by which SQL separates row-level logic from set-level computation. Notice also that the HAVING clause, which appears downstream in the pipeline, is the only place where you can reference aggregate results to filter groups.

How WHERE and Aggregates Interact — The Execution Model

To reason precisely about SQL queries that mix filtering and aggregation, it helps to formalize the execution model. Although database engines use sophisticated optimizers that may reorder physical operations, the logical semantics are always defined by the standard processing order. This section presents the conceptual pipeline as a series of transformations on intermediate result sets.

LOGICAL PIPELINE
Result = π(σ_HAVING(γ(σ_WHERE(R))))
R = base relation (FROM), σ_WHERE = selection predicate (WHERE), γ = grouping and aggregation (GROUP BY + aggregate functions), σ_HAVING = group-level selection (HAVING), π = projection (SELECT list). Each operator consumes the output of the previous one.
AGGREGATE OVER FILTERED SET
AGG(column) = AGG({r.column | r ∈ R ∧ WHERE(r) = TRUE})
The aggregate function AGG (e.g., SUM, COUNT, AVG) operates only on the multiset of values drawn from rows r that satisfy the WHERE predicate. Rows eliminated by WHERE contribute nothing to the aggregate.
CRITICAL CONSTRAINT
WHERE clause ≠ f(AGG(…)) — aggregate functions are forbidden in WHERE
Because WHERE evaluates before grouping, no aggregate result exists yet. Referencing an aggregate in WHERE is a semantic error that the parser will reject. To filter on aggregate results, use HAVING.
Common Mistake
Writing WHERE COUNT(*) > 5 is a syntax error in every SQL dialect. At the point WHERE executes, no groups exist and no counts have been computed. The correct form is HAVING COUNT(*) > 5. However, you can use a subquery in WHERE that itself contains an aggregate — the subquery is a separate scope with its own processing pipeline.

Understanding this constraint formally clarifies many design decisions. When you need to exclude individual rows that do not meet a condition — say, orders placed before a certain date — you use WHERE. When you need to exclude entire groups based on a summary statistic — say, customers with fewer than three orders — you use HAVING. The two clauses are not interchangeable; they operate at different stages of the pipeline and on different granularities of data.

Common Patterns — WHERE with Aggregates in Practice

In real-world SQL, the interplay between WHERE and aggregates appears in several recurring patterns. This section catalogs the most important ones, distinguishing correct usage from common anti-patterns. The visual below maps each pattern to its position in the logical pipeline.

The decision tree shows the fundamental question: does the filter condition reference an aggregate? If no, use WHERE. If yes, use HAVING. The bottom two boxes show how both clauses can coexist in a single query and the anti-pattern that results in a syntax error.
Common patterns for combining WHERE with aggregate functions
PatternSQL ExampleExplanation
WHERE before aggregate (no GROUP BY)SELECT AVG(salary) FROM employees WHERE dept = 'Eng';Filters to Engineering rows first, then computes average salary over that subset.
WHERE + GROUP BY + aggregateSELECT dept, COUNT(*) FROM employees WHERE hire_date > '2020-01-01' GROUP BY dept;Only employees hired after 2020 are counted; groups are formed from the filtered set.
WHERE + GROUP BY + HAVINGSELECT dept, SUM(salary) FROM employees WHERE status = 'active' GROUP BY dept HAVING SUM(salary) > 500000;WHERE removes inactive employees; HAVING removes departments whose total salary for active employees is ≤ 500K.
Aggregate in subquery inside WHERESELECT * FROM employees WHERE salary > (SELECT AVG(salary) FROM employees);The subquery computes a scalar aggregate independently; the outer WHERE compares each row's salary to that scalar.

Worked Example — Revenue Report for Active Products

Consider a table sales with columns product_id, category, sale_amount, sale_date, and is_returned (boolean). We want to find the total revenue per category for non-returned sales in 2024, showing only categories with revenue exceeding $10,000.

Revenue Report Query
1
Step 1 — Identify the Row-Level FiltersTwo conditions apply to individual rows before aggregation: the sale must not be returned (is_returned = FALSE) and the sale must have occurred in 2024 (sale_date >= '2024-01-01' AND sale_date < '2025-01-01'). Both belong in the WHERE clause because they evaluate per-row attributes, not aggregate results.
WHERE is_returned = FALSE AND sale_date >= '2024-01-01' AND sale_date < '2025-01-01'
2
Step 2 — Identify the Grouping ColumnThe report requires totals per category. Therefore, we group by category. Only rows that survived the WHERE filter will form these groups.
GROUP BY category
3
Step 3 — Choose the Aggregate FunctionWe need total revenue, so we apply SUM(sale_amount) in the SELECT list. This function will sum only the non-returned 2024 sales within each category because of the WHERE clause.
SELECT category, SUM(sale_amount) AS total_revenue
4
Step 4 — Apply the Group-Level FilterThe requirement to show only categories exceeding $10,000 is a condition on the aggregate result, not on individual rows. This must go in the HAVING clause, not WHERE.
HAVING SUM(sale_amount) > 10000
5
Step 5 — Assemble the Complete QueryPutting all pieces together in the correct clause order:
SELECT category, SUM(sale_amount) AS total_revenue FROM sales WHERE is_returned = FALSE AND sale_date >= '2024-01-01' AND sale_date < '2025-01-01' GROUP BY category HAVING SUM(sale_amount) > 10000 ORDER BY total_revenue DESC;
💡 Why Not Filter Returns in HAVING?
You might wonder whether moving the is_returned check to HAVING would produce the same result. It would not, and in fact it would be syntactically invalid because is_returned is not an aggregate expression nor a grouped column (unless you group by it). Even if it were somehow valid, it would conceptually change the meaning: HAVING filters groups, not individual rows. Always ask: 'Does this condition apply to a single row or to a summary of many rows?'

WHERE vs. HAVING — Strengths, Limitations & Performance

A thorough understanding of the WHERE–HAVING divide extends beyond correctness into performance and optimizer behavior. Modern query planners can sometimes push predicates between stages, but the logical semantics — and therefore the result set — are always governed by the standard processing order. The table below summarizes the key differences along multiple dimensions.

Comprehensive comparison of WHERE and HAVING
DimensionWHEREHAVING
Evaluation StageBefore GROUP BY and aggregationAfter GROUP BY and aggregation
Operand GranularityIndividual rowsGroups (aggregated results)
Can Reference Aggregates?No — syntax error if attemptedYes — this is its primary purpose
Can Reference Non-Aggregated Columns?Yes — any column from FROM tablesOnly columns in GROUP BY list (or inside aggregate expressions)
Index UtilizationCan leverage B-tree and other indexes for fast row eliminationNo index benefit — filtering occurs on computed aggregates
Effect on Aggregate InputReduces the number of rows aggregated — changes aggregate valuesDoes not change aggregate values — only removes entire groups from the output
Performance ImplicationGenerally faster to eliminate rows early, reducing work for GROUP BY and aggregationAggregation runs on all qualifying rows first; groups are filtered afterward
PERFORMANCE INSIGHT
Think of WHERE as pruning branches from a tree before you count the remaining leaves (aggregate), and HAVING as removing entire trees from a forest after you have already counted the leaves on each one. Pruning early (WHERE) reduces the total work the engine must do because fewer rows flow into the aggregation step. Place every filter that can logically be expressed as a row-level predicate in WHERE, even if an equivalent HAVING formulation exists, to give the optimizer the best chance to use indexes and minimize I/O.

Connection to Advanced Topics — Subqueries, CTEs, and Window Functions

The WHERE-before-aggregation rule provides the foundation for understanding more advanced SQL constructs. As queries grow in complexity, the interaction between filtering and aggregation manifests in several additional forms. Correlated subqueries allow aggregate values from one scope to be used in the WHERE clause of another scope. Common Table Expressions (CTEs) let you compute an aggregation step and then filter its results with a WHERE clause in the outer query. Window functions introduce a computation layer that occurs after WHERE but does not collapse rows, offering aggregate-like calculations without GROUP BY.

How advanced SQL constructs extend the WHERE–aggregate interaction
ConceptRelationship to WHERE + Aggregates
Scalar Subquery in WHEREA subquery like WHERE salary > (SELECT AVG(salary) FROM employees) is valid because the inner SELECT has its own pipeline. The aggregate resolves to a scalar before the outer WHERE evaluates it.
Correlated SubqueryA subquery referencing the outer table — e.g., WHERE salary > (SELECT AVG(salary) FROM employees e2 WHERE e2.dept = e1.dept) — runs the inner aggregate for each outer row, enabling per-group comparisons at the row level.
CTE + WHEREA CTE can pre-compute aggregates, and the outer query treats them as ordinary columns: WITH dept_totals AS (SELECT dept, SUM(salary) AS total FROM employees GROUP BY dept) SELECT * FROM dept_totals WHERE total > 500000; Here WHERE filters rows of the CTE, not the base table.
Window FunctionsWindow functions like SUM(salary) OVER (PARTITION BY dept) compute after WHERE but do not reduce rows. You cannot use window functions in WHERE directly; wrap them in a subquery or CTE if you need to filter on their results.

As you progress to more complex analytics — rolling averages, percentile calculations, and multi-level aggregations — the conceptual model established in this lesson remains the invariant: WHERE always filters rows before aggregation within its scope. Subqueries and CTEs simply introduce new scopes, each with its own FROM → WHERE → GROUP BY → HAVING pipeline. Mastering this one principle makes every subsequent SQL concept easier to reason about.

Practice Problems

The following problems use a table called transactions with columns: id (INT), customer_id (INT), amount (DECIMAL), txn_date (DATE), txn_type (VARCHAR) with values 'purchase' or 'refund', and store_id (INT).

PROBLEM 1CONCEPTUAL
Explain in your own words why the following query produces a syntax error: SELECT store_id, AVG(amount) FROM transactions WHERE AVG(amount) > 100 GROUP BY store_id; What is the correct way to express this intent?
PROBLEM 2BASIC CALCULATION
Write a query that computes the total purchase amount (not refunds) per customer. Only include transactions from the year 2024.
PROBLEM 3INTERMEDIATE
Write a query to find stores where the average purchase amount in 2024 exceeds $200, but exclude refund transactions from the calculation entirely. Return the store_id and the computed average, ordered by average descending.
PROBLEM 4APPLIED
A business analyst asks: 'Give me every customer whose 2024 purchase total is above the overall average 2024 purchase total across all customers.' Write a single SQL statement that answers this question. Hint: you will need a subquery.
PROBLEM 5CRITICAL THINKING
Consider these two queries: (A) SELECT store_id, COUNT(*) FROM transactions WHERE amount > 50 GROUP BY store_id; (B) SELECT store_id, COUNT(*) FROM transactions GROUP BY store_id HAVING COUNT(*) > 50; Do they return the same result? Explain the semantic difference, and construct a scenario (with sample data) where query A returns a row that query B does not, and vice versa.

Summary — Aggregates with WHERE Filters

SQL's logical processing order — FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY — is the master key to understanding how aggregate functions interact with filters. The WHERE clause is a row-level filter that executes before any grouping or aggregation, determining which rows are eligible inputs to functions like SUM, COUNT, AVG, MIN, and MAX. Because aggregates have not yet been computed when WHERE runs, placing an aggregate function inside a WHERE clause is always a syntax error.

To filter on aggregate results, use HAVING, which evaluates after groups are formed. When a query requires both kinds of filtering, place row-level conditions in WHERE and group-level conditions in HAVING. This not only ensures correctness but also improves query performance by reducing the number of rows the engine must aggregate. For advanced scenarios, scalar subqueries and CTEs allow aggregate values to be used indirectly in WHERE by introducing a separate processing scope.

Varsity Tutors • SQL • Aggregates with WHERE — Use aggregates with WHERE filters appropriately (conceptual)