Historical Context & Motivation
The relational model, proposed by E.F. Codd in 1970, initially provided only basic aggregation through what would become the GROUP BY clause. GROUP BY collapses rows into summary groups, a powerful but inherently destructive operation—once rows are aggregated, the original row-level detail is lost. For decades, analysts worked around this limitation using self-joins and correlated subqueries, but these approaches were verbose, error-prone, and often inefficient. The SQL standard needed a mechanism to compute aggregates without collapsing the result set, and that need ultimately gave rise to window functions.
The central question this lesson addresses is deceptively simple: if both GROUP BY and window functions perform aggregation, what happens when you use them together in the same query? The answer lies in SQL's logical order of operations, which dictates that GROUP BY executes before window functions. Misunderstanding this order is the root cause of a pervasive class of SQL bugs.
Core Principles & Definitions
To reason correctly about queries that combine GROUP BY and window functions, you must internalize three foundational ideas: the logical query execution order, the distinction between aggregate and window contexts, and the concept of the virtual table that exists at each processing stage. These principles are not implementation details—they are part of the SQL standard's semantic model, and every conforming database engine must behave as if it follows this order, regardless of internal optimizations.
Logical Execution Order
GROUP BY Collapses Rows
Window Functions Preserve Rows
The 'Input' to Window Functions
You Cannot Filter on Window Functions in WHERE or HAVING
Visual Explanation: SQL Logical Execution Pipeline
The diagram above is the single most important mental model for this topic. When a query contains both GROUP BY and a window function, the window function does not operate on the original table—it operates on the post-aggregation virtual table. If you write SUM(amount) OVER (PARTITION BY dept) alongside GROUP BY dept, the PARTITION BY is partitioning over the already-grouped rows—each of which already represents an entire department. This is almost never the intent, and it produces a result identical to the aggregate SUM(amount) for that group. Understanding this pipeline eliminates an entire category of SQL errors.
How the Interaction Works — Step by Step
Let us formalize what happens when a query combines GROUP BY with a window function. Consider a table orders(order_id, region, product, amount) and the following query:
SELECT region, SUM(amount) AS total, ROW_NUMBER() OVER (ORDER BY SUM(amount) DESC) AS rank FROM orders GROUP BY region;This query actually works correctly—but not for the reason most beginners assume. Let us trace through the logical execution to see precisely why.
Phase-by-Phase Trace
orders table. Suppose there are 1,000 rows across 4 regions.region) and any aggregate expressions (SUM(amount)). Individual order rows are gone.ROW_NUMBER() OVER (ORDER BY SUM(amount) DESC). Critically, this window function sees only the 4 grouped rows. It ranks them 1 through 4 by their aggregate totals. Since SUM(amount) was already computed during GROUP BY, it is available as a column in the virtual table. The result here is actually sensible—we are ranking regions by total sales.When It Goes Wrong
The confusion arises when developers intend the window function to operate on the pre-aggregation detail rows. For example, suppose you want to rank individual orders within each region, but you also want the region total in the same output. Writing ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) inside a query that also has GROUP BY region will not rank individual orders—it will attempt to rank the single grouped row per region, producing a rank of 1 for every region. The solution is to separate the two concerns using a subquery or CTE: compute the window function on the detail rows in an inner query, then aggregate in an outer query, or vice versa.
Common Patterns and Anti-Patterns
The interaction between GROUP BY and window functions falls into three categories: correct and intentional usage, syntactically valid but semantically wrong queries, and outright errors that the database engine rejects. The second category is the most dangerous because the query runs without error but produces misleading results.
The most instructive case is Anti-Pattern A in the center column. When you write SUM(sal) OVER (PARTITION BY dept) in a query grouped by dept, the window function partitions the post-GROUP BY result set by department. Since GROUP BY already guarantees one row per department, every partition contains exactly one row. The window SUM over a single-row partition simply returns the value in that row—making the window function entirely redundant. This kind of code smells fine during code review but delivers no additional analytical power and can mislead readers about the query's semantics.
SUM(SUM(rev)) OVER (ORDER BY month). This is not a typo. The inner SUM(rev) is the GROUP BY aggregate (monthly revenue). The outer SUM(...) OVER (...) is the window function that computes a running total of those monthly sums. This nested syntax is the legitimate way to apply a window function to an aggregate result within the same query.Worked Example: Fixing a Broken Query
A developer wants to produce a report from a sales(sale_id, region, rep_name, amount, sale_date) table. The report should show each sales representative, their total sales, and their rank within their region. The developer writes this query:
SELECT region, rep_name, SUM(amount) AS total_sales, RANK() OVER (PARTITION BY region ORDER BY SUM(amount) DESC) AS region_rank FROM sales GROUP BY region;region alone, but the SELECT list includes rep_name, which is neither in the GROUP BY list nor wrapped in an aggregate function. In PostgreSQL or SQL Server, this query will fail with an error. In MySQL with permissive ONLY_FULL_GROUP_BY disabled, it will silently return an arbitrary representative name per region—not the intended behavior.rep_name to the GROUP BY clause, we ensure each (region, rep_name) pair becomes one row. The window function then sees multiple rows per region partition and can meaningfully rank them.SELECT region, rep_name, SUM(amount) AS total_sales, RANK() OVER (PARTITION BY region ORDER BY SUM(amount) DESC) AS region_rank FROM sales GROUP BY region, rep_name;GROUP BY vs. Window Functions — When to Use Which
Choosing between GROUP BY and window functions—or deciding to combine them—depends on what the final result set should look like. The following comparison table highlights the key differences across several dimensions that matter when designing queries.
| Dimension | GROUP BY | Window Function |
|---|---|---|
| Row count | Reduces rows — one output row per group | Preserves rows — one output row per input row |
| Detail access | Original row-level columns are lost unless included in GROUP BY | All original columns remain accessible in SELECT |
| Execution phase | Phase 3 (before HAVING and SELECT) | Phase 5 (during SELECT, after GROUP BY) |
| Ranking capability | Not directly — requires a subquery or self-join | Native — ROW_NUMBER, RANK, DENSE_RANK, NTILE |
| Running totals | Not directly — requires correlated subquery | Native — SUM(...) OVER (ORDER BY ...) |
| Use with HAVING | Yes — HAVING filters groups after aggregation | No — window results cannot be referenced in HAVING |
| Combining both | Produces the grouped result set | Operates on the grouped result set — sees only grouped rows |
Connection to Advanced Patterns
Understanding how GROUP BY and window functions interact is a prerequisite for several advanced SQL patterns. As you progress, you will encounter scenarios where the basic principles discussed here extend into more complex territory, including nested window functions, multiple levels of aggregation, and the use of Common Table Expressions (CTEs) to separate aggregation from windowing across query layers.
| This Lesson's Concept | Advanced Extension |
|---|---|
| Window fn sees grouped rows | Multi-layer CTEs: first CTE groups, second CTE applies window fn, third CTE groups again — each layer operates on the previous layer's output |
| SUM(SUM(x)) OVER (...) nested aggregate | GROUPING SETS, CUBE, ROLLUP produce multiple aggregation levels in one query; window functions on top of these require careful reasoning about which level each row represents |
| Cannot filter on window fn in WHERE/HAVING | QUALIFY clause (Snowflake, BigQuery, DuckDB) — a new logical phase after SELECT that filters on window function results without requiring a CTE wrapper |
| PARTITION BY on grouped columns | Named window definitions (WINDOW clause) allow reusable window specs across multiple window functions in the same SELECT, reducing redundancy and errors |
| Logical execution order awareness | Query plan analysis — understanding physical execution via EXPLAIN ANALYZE and how the optimizer may reorder or merge GROUP BY and window computations for performance |
The QUALIFY clause deserves special mention. In standard SQL (as of 2023), there is no QUALIFY clause—it is a vendor extension. Its adoption is growing rapidly because it directly addresses the frustration of wrapping queries in CTEs just to filter on window function results. If you are working with Snowflake, Google BigQuery, or DuckDB, QUALIFY is available and eliminates one of the most common reasons developers incorrectly try to put window functions in HAVING. In PostgreSQL and SQL Server, you still need the CTE approach, making it even more important to internalize the logical execution order.
Practice Problems
PARTITION BY dept in a query that also has GROUP BY dept creates partitions of exactly one row each. Why does this make the window function's computation trivial?orders(order_id, customer_id, amount), write a query that returns each customer's total spending and their rank among all customers by total spending (highest first). State clearly whether you need GROUP BY, a window function, or both.SELECT department, employee_name, salary, AVG(salary) OVER (PARTITION BY department) AS dept_avg FROM employees GROUP BY department;transactions(txn_id, txn_date, amount) table. The report should show: (1) the month, (2) total revenue for that month, (3) a cumulative year-to-date revenue. Write the query, explaining why the nested aggregate pattern SUM(SUM(amount)) is necessary.Lesson Summary
The interaction between GROUP BY and window functions is governed entirely by SQL's logical execution order: FROM → WHERE → GROUP BY → HAVING → SELECT (window functions) → ORDER BY → LIMIT. Because GROUP BY executes before window functions, the window function always operates on the post-aggregation virtual table, never on the original detail rows. This means that PARTITION BY on the same column as GROUP BY creates single-row partitions, making the window function redundant. To apply a window function to detail rows, either remove GROUP BY or use a CTE to separate the two operations.
When you intentionally combine both, use the nested aggregate pattern such as SUM(SUM(x)) OVER (...) where the inner aggregate is the GROUP BY computation and the outer function is the window computation. Always verify your query's correctness by asking: how many rows does GROUP BY produce, and is that the granularity the window function needs? If the answer is no, restructure your query. Remember that window function results cannot be filtered in WHERE or HAVING—use a CTE or the QUALIFY clause (where available) instead.