SQL • Window Functions

Window Functions & GROUP BY — Avoid mixing window functions with GROUP BY incorrectly (conceptual)

Understanding why combining window functions with GROUP BY produces unexpected results and how to reason about SQL's logical execution order.

SQL illustration with a database, structured table, and query code symbols
Inside this lesson 10 sections 5 questions 4 figures

SECTION 1

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.

1986 • SQL-86 Standard

The first ANSI SQL standard formalized SELECT, FROM, WHERE, and GROUP BY. Aggregation required collapsing rows—there was no way to compute running totals or rankings while preserving row-level detail.

1999 • SQL:1999 — OLAP Extensions Proposed

The SQL:1999 standard introduced common table expressions and recursive queries, but window functions were still absent. OLAP vendors like Oracle began shipping proprietary analytic functions.

2003 • SQL:2003 — Window Functions Standardized

The OVER clause, PARTITION BY, ORDER BY within windows, and ranking functions (ROW_NUMBER, RANK, DENSE_RANK) were formally standardized. This created a new logical processing phase distinct from GROUP BY.

2012 • PostgreSQL 8.4–9.x Adoption

PostgreSQL became one of the first open-source databases to offer robust window function support. This accelerated adoption in academia and industry, but also exposed a common class of bugs: mixing window functions with GROUP BY incorrectly.

2020s • Ubiquitous Window Functions

Today, every major RDBMS (PostgreSQL, MySQL 8+, SQL Server, Oracle, SQLite 3.25+) supports window functions. Understanding how they interact with GROUP BY is now a core competency tested in technical interviews and database courses.

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.

SECTION 2

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.

1

Logical Execution Order

SQL processes clauses in a fixed logical order: FROM → WHERE → GROUP BY → HAVING → SELECT (including window functions) → ORDER BY → LIMIT. Window functions evaluate after GROUP BY has already collapsed rows.
2

GROUP BY Collapses Rows

GROUP BY reduces the result set so that each group becomes exactly one row. Any column in SELECT must either be in the GROUP BY list or wrapped in an aggregate function. The original row-level detail is irrecoverable at this point.
3

Window Functions Preserve Rows

Window functions compute values across a set of rows related to the current row, but they never collapse the result set. They add information to each row without removing any rows from the output.
4

The 'Input' to Window Functions

When GROUP BY is present, window functions operate on the already-grouped result. They see the collapsed rows, not the original detail rows. This means PARTITION BY and ORDER BY in the OVER clause reference grouped columns and aggregate results.
5

You Cannot Filter on Window Functions in WHERE or HAVING

Because window functions execute during the SELECT phase, you cannot reference them in WHERE (too early) or HAVING (also too early). To filter on a window function result, wrap the query in a CTE or subquery.
✦

KEY TAKEAWAY

Think of SQL query execution like a factory assembly line. GROUP BY is the compactor that crushes raw materials into dense blocks. Window functions are the quality inspector who walks along the conveyor belt after compaction, measuring and labeling each block. The inspector never sees the original raw materials—only the compacted blocks. If you expected the inspector to measure the raw materials, you placed them at the wrong station on the line.

SECTION 3

Visual Explanation: SQL Logical Execution Pipeline

SQL Logical Execution Order — Where GROUP BY and Window Functions LiveFROMWHEREGROUP BYrows collapse hereHAVINGSELECT + Window Fnswindow functions evaluate hereORDER BYLIMITData at Each StageBefore GROUP BY (6 rows):Alice | Sales | 100Bob | Sales | 200Carol | Eng | 150Dave | Eng | 300Eve | Sales | 250Frank | Eng | 180After GROUP BY dept (2 rows):Sales | SUM=550 | COUNT=3Eng | SUM=630 | COUNT=3Window fn sees only 2 rows!
The pipeline diagram shows the six major logical phases. Notice that GROUP BY (phase 3) collapses 6 detail rows into 2 grouped rows before the SELECT + Window Functions phase (phase 5) even begins. The inset panel shows the concrete data at each stage: the window function never has access to the 6 original rows.

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.

SECTION 4

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:

⚠

THE PROBLEMATIC 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

Logical Execution Trace

  1. 1

    Phase 1 — FROM

    The engine loads all rows from the orders table. Suppose there are 1,000 rows across 4 regions.
    Virtual table: 1,000 rows × 4 columns
  2. 2

    Phase 2 — WHERE

    No WHERE clause is present, so all 1,000 rows pass through unfiltered.
    Virtual table: still 1,000 rows
  3. 3

    Phase 3 — GROUP BY region

    The 1,000 rows are collapsed into 4 groups—one per distinct region value. From this point forward, only 4 rows exist in the virtual table. Each row carries the grouped column (region) and any aggregate expressions (SUM(amount)). Individual order rows are gone.
    Virtual table: 4 rows (one per region)
  4. 4

    Phase 4 — HAVING

    No HAVING clause, so all 4 grouped rows survive.
  5. 5

    Phase 5 — SELECT + Window Functions

    Now the engine evaluates 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.
    Output: 4 rows, each with region, total, and rank

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.

SECTION 5

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.

Three Categories of GROUP BY + Window Function Interaction✓ CORRECTPattern A: Rank Grouped ResultsSELECT dept, SUM(sal) AS total, RANK() OVER (ORDER BY SUM(sal))FROM emp GROUP BY dept;Intent: rank departments bytotal salary. Window fn seesgrouped rows — exactly right.Pattern B: Running TotalSELECT month, SUM(rev) AS m_rev, SUM(SUM(rev)) OVER (ORDER BY month)FROM salesGROUP BY month;SUM(SUM(rev)): outer SUM isthe window fn, inner SUM isthe aggregate. Produces acumulative total over months.⚠ MISLEADINGAnti-Pattern A: Redundant WindowSELECT dept, SUM(sal) AS total, SUM(sal) OVER (PARTITION BY dept)FROM emp GROUP BY dept;After GROUP BY, each dept isone row. PARTITION BY dept inthe window fn creates partitionsof size 1. Result = same asthe plain SUM(sal). Redundant!Anti-Pattern B: Wrong GranularitySELECT dept, COUNT(*) AS cnt, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY sal)FROM emp GROUP BY dept;Developer wanted to rankemployees within departments,but GROUP BY already collapsedemployees. Also, 'sal' is notin GROUP BY → error or undefined!✗ ERRORError A: Non-grouped ColumnSELECT dept, name, SUM(sal), RANK() OVER (ORDER BY name)FROM emp GROUP BY dept;'name' is not in GROUP BY andnot in an aggregate. This isa compile-time error in strictSQL modes (PostgreSQL, SQLServer). MySQL may silentlypick an arbitrary name value.Error B: Window in WHERESELECT dept, SUM(sal)FROM empWHERE RANK() OVER (ORDER BY sal) <= 3GROUP BY dept;Window functions cannot appearin WHERE because WHERE runsbefore SELECT. Must use a CTE:WITH ranked AS (SELECT ..., RANK() OVER ... AS rk)SELECT * FROM rankedWHERE rk <= 3;
Three columns compare correct patterns (left, green), misleading but syntactically valid anti-patterns (center, amber), and outright errors (right, red). The center column is the most dangerous because the query executes without error yet produces unexpected 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.

💡

THE NESTED AGGREGATE TRICK

Pattern B in the correct column shows 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.

SECTION 6

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:

⚠

BUGGY 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;

Diagnosing and Fixing the Query

  1. 1

    Step 1 — Identify the Bug

    The GROUP BY clause groups by 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.
    Bug: rep_name is not in GROUP BY and not aggregated
  2. 2

    Step 2 — Determine the Correct Grouping Level

    The developer wants to rank individual representatives within each region. This means the grouping level must be (region, rep_name) so that each rep gets their own row with their total sales. The window function will then partition by region and rank over those per-rep rows.
    Required GROUP BY: region, rep_name
  3. 3

    Step 3 — Write the Fixed Query

    By adding 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.
  4. 4

    Step 4 — Verify the Fixed 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, rep_name;
    Correct: Each rep gets their own row; RANK partitions over multiple reps per region
  5. 5

    Step 5 — Trace the Logical Execution

    If there are 20 reps across 4 regions, GROUP BY (region, rep_name) produces 20 rows. The window function PARTITION BY region creates 4 partitions, each containing roughly 5 reps. RANK orders them by SUM(amount) DESC within each partition, producing meaningful ranks from 1 to ~5 per region. This is exactly the intent.
    20 rows output, each with a meaningful region_rank
🔍

DIAGNOSTIC RULE

When debugging a query that mixes GROUP BY and window functions, ask yourself: how many rows does GROUP BY produce, and is that the number of rows I want the window function to see? If the answer is no, your GROUP BY granularity is wrong—either too coarse (losing detail the window function needs) or missing entirely (you may not need GROUP BY at all and should use only the window function).

SECTION 7

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.

DimensionGROUP BYWindow Function
Row countReduces rows — one output row per groupPreserves rows — one output row per input row
Detail accessOriginal row-level columns are lost unless included in GROUP BYAll original columns remain accessible in SELECT
Execution phasePhase 3 (before HAVING and SELECT)Phase 5 (during SELECT, after GROUP BY)
Ranking capabilityNot directly — requires a subquery or self-joinNative — ROW_NUMBER, RANK, DENSE_RANK, NTILE
Running totalsNot directly — requires correlated subqueryNative — SUM(...) OVER (ORDER BY ...)
Use with HAVINGYes — HAVING filters groups after aggregationNo — window results cannot be referenced in HAVING
Combining bothProduces the grouped result setOperates on the grouped result set — sees only grouped rows

Comparison of GROUP BY and window functions across key dimensions

🧭

DECISION HEURISTIC

Use GROUP BY when your report requires summary rows (e.g., total per department). Use window functions when your report requires detail rows with context (e.g., each employee's salary alongside their department average). Combine them when you need to enrich summary rows with cross-group analytics (e.g., ranking departments by total salary). In that last case, always verify that the GROUP BY granularity matches what the window function should see.

SECTION 8

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 ConceptAdvanced Extension
Window fn sees grouped rowsMulti-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 aggregateGROUPING 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/HAVINGQUALIFY 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 columnsNamed window definitions (WINDOW clause) allow reusable window specs across multiple window functions in the same SELECT, reducing redundancy and errors
Logical execution order awarenessQuery plan analysis — understanding physical execution via EXPLAIN ANALYZE and how the optimizer may reorder or merge GROUP BY and window computations for performance

How this lesson's concepts connect to advanced SQL patterns

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.

SECTION 9

Practice Problems

Question 1: CONCEPTUAL

Explain in your own words why a window function that uses 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?
Show answer
GROUP BY dept collapses all rows in the same department into a single summary row, so after GROUP BY executes, there is exactly one row per department in the virtual table. When the window function then evaluates with PARTITION BY dept, it creates a partition for each distinct dept value—but each such partition contains only one row. A window aggregate like SUM or AVG over a single-row partition simply returns the value of that row unchanged. Ranking functions like RANK() or ROW_NUMBER() will always return 1. The window function adds computational overhead but produces no new information beyond what the GROUP BY aggregate already computed.

Question 2: BASIC CALCULATION

Given a table 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.
Show answer
You need both. GROUP BY customer_id collapses orders to one row per customer with SUM(amount). Then a window function ranks those grouped rows. The correct query is: SELECT customer_id, SUM(amount) AS total_spent, RANK() OVER (ORDER BY SUM(amount) DESC) AS spending_rank FROM orders GROUP BY customer_id; Here, GROUP BY produces one row per customer, and RANK() operates on those grouped rows to assign ranks. The window function correctly sees the post-aggregation result set of N customers (not the thousands of individual order rows).

Question 3: INTERMEDIATE

A colleague writes the following query and is confused by the output. Identify the problem and provide the corrected query. SELECT department, employee_name, salary, AVG(salary) OVER (PARTITION BY department) AS dept_avg FROM employees GROUP BY department;
Show answer
There are two problems. First, employee_name and salary are not in the GROUP BY list and are not wrapped in aggregate functions, so in strict SQL mode this query will fail. Second, the intent—showing each employee alongside their department average—requires NO GROUP BY at all. The window function alone provides the department-level aggregate without collapsing rows. The fix is simply to remove the GROUP BY clause: SELECT department, employee_name, salary, AVG(salary) OVER (PARTITION BY department) AS dept_avg FROM employees; This preserves every employee row and adds the department average as a computed column. The window function handles the partitioned aggregation while keeping the row-level detail intact.

Question 4: APPLIED

A data analyst needs a monthly revenue report from a 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.
Show answer
The query requires GROUP BY to collapse individual transactions into monthly totals, plus a window function to compute a running sum across months. The correct query is: SELECT DATE_TRUNC('month', txn_date) AS month, SUM(amount) AS monthly_rev, SUM(SUM(amount)) OVER (ORDER BY DATE_TRUNC('month', txn_date)) AS ytd_rev FROM transactions GROUP BY DATE_TRUNC('month', txn_date); The nested SUM(SUM(amount)) is necessary because the inner SUM(amount) is the GROUP BY aggregate—it computes the monthly total. The outer SUM(...) OVER (...) is the window function that computes the cumulative sum across months. You cannot write just SUM(amount) OVER (...) because amount is a detail-level column that is not directly available after GROUP BY; only the aggregate expression SUM(amount) survives. The nesting tells the engine: first aggregate to get monthly totals, then apply the window sum over those totals.

Question 5: CRITICAL THINKING

Consider a scenario where you need to find the top-selling product in each category, where 'top-selling' means the product with the highest total sales amount. You might be tempted to use GROUP BY category, product with a window function and then filter. Discuss why you cannot use HAVING to filter on the window function result, propose a correct solution using a CTE, and explain how the QUALIFY clause (in databases that support it) simplifies this pattern.
Show answer
HAVING cannot reference window function results because HAVING executes at logical phase 4 (after GROUP BY but before SELECT), while window functions execute at phase 5 (during SELECT). By the time the engine evaluates HAVING, the window function has not yet been computed. The correct CTE approach is: WITH ranked AS (SELECT category, product, SUM(amount) AS total_sales, RANK() OVER (PARTITION BY category ORDER BY SUM(amount) DESC) AS rk FROM sales GROUP BY category, product) SELECT category, product, total_sales FROM ranked WHERE rk = 1; The CTE materializes the grouped and ranked result, and the outer query filters on the rank column, which is now an ordinary column. In databases supporting QUALIFY, this simplifies to: SELECT category, product, SUM(amount) AS total_sales FROM sales GROUP BY category, product QUALIFY RANK() OVER (PARTITION BY category ORDER BY SUM(amount) DESC) = 1; QUALIFY acts as a post-window-function filter, eliminating the need for the CTE wrapper entirely. This is cleaner and often more performant since the engine can fuse the filtering with the window computation.

SUMMARY

Lesson Summary

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.

Varsity Tutors • SQL • Window Functions & GROUP BY — Avoid mixing window functions with GROUP BY incorrectly (conceptual)