SQL Window Functions vs GROUP BY: When to Use Which
- GROUP BY collapses rows. A window function keeps them. That single difference decides almost every case.
- The test: if you need the detail and the summary on the same row, it is a window function. If you only want the summary, GROUP BY is simpler and faster.
- Only windows can do running totals, ranking, "each row against its group average", and "the previous row's value".
- You cannot filter a window in WHERE — it runs after WHERE does. Wrap it in a CTE and filter outside.
The one-sentence answer
Use GROUP BY when you want fewer rows than you started with, and a window function when you want the same rows with extra context attached. Both compute the same aggregates; they differ entirely in what happens to your result set, and that is what you should be choosing between.
What GROUP BY actually does to your rows
Five orders from two customers go into a GROUP BY customer_id and two rows come out. This is not a side effect, it is the point: you asked for one answer per customer, so the individual orders no longer exist in the result. You cannot then ask which order was the largest, or how each order compared to that customer's average, because there are no orders left to ask about.
SELECT customer_id, SUM(order_total) AS customer_total
FROM orders
GROUP BY customer_id;
-- 5 rows in, 2 rows out
That reduction is exactly what you want for a summary report. It is exactly what you do not want when the detail is the point.
What a window function does instead
A window function runs the same aggregate over a defined set of rows — the "window" — and writes the result onto every row in it. Nothing is collapsed.
SELECT
customer_id,
order_id,
order_total,
SUM(order_total) OVER (PARTITION BY customer_id) AS customer_total
FROM orders;
-- 5 rows in, 5 rows out, each carrying its customer's total
PARTITION BY is the window's version of GROUP BY: it says which rows count as one group. Leave it out entirely and the window spans the whole result set, which is how you compute each row's share of the grand total in a single pass.
The test that settles it
Ask what your output needs to look like, not what calculation you are doing.
| What you need | Use |
|---|---|
| Total revenue per region, one line each | GROUP BY |
| Every order, plus its customer's lifetime total | Window |
| Count of orders per status for a dashboard tile | GROUP BY |
| Each order as a percentage of that month's revenue | Window |
| The three best-selling products in every category | Window |
| Average salary per department | GROUP BY |
| Each employee's salary against their department average | Window |
| A running balance down a transaction log | Window |
The four things only windows can do
1. Running totals
Add ORDER BY inside the OVER() and the window stops being the whole partition and becomes "every row up to this one":
SELECT
txn_date,
amount,
SUM(amount) OVER (PARTITION BY account_id ORDER BY txn_date) AS running_balance
FROM transactions;
That single clause change is the entire difference between a customer total and a running balance. GROUP BY has no equivalent, because a running total needs one output row per input row by definition.
2. Ranking
Three functions, and the difference between them is how they handle ties:
ROW_NUMBER() OVER (ORDER BY sales DESC) -- 1, 2, 3, 4 (ties broken arbitrarily)
RANK() OVER (ORDER BY sales DESC) -- 1, 2, 2, 4 (ties share, then skip)
DENSE_RANK() OVER (ORDER BY sales DESC) -- 1, 2, 2, 3 (ties share, no gap)
This is why "find the second highest salary" is such a common interview question: the naive answer works until two people tie for first, and choosing between RANK and DENSE_RANK is choosing what "second highest" means.
3. Comparing a row to its group
SELECT
name,
department,
salary,
salary - AVG(salary) OVER (PARTITION BY department) AS vs_dept_average
FROM employees;
With GROUP BY this needs a second query and a join back to the detail. With a window it is one expression.
4. Looking at the previous or next row
SELECT
month,
revenue,
revenue - LAG(revenue) OVER (ORDER BY month) AS change_from_last_month
FROM monthly_revenue;
LAG and LEAD reach backwards and forwards in the ordered window. Month-over-month change, time between events, and gap detection all reduce to this one pattern.
The mistake everyone makes once
You write a ranking window, then try to filter on it:
-- This fails, in every database
SELECT product, category, sales,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) AS rn
FROM products
WHERE rn <= 3;
SQL evaluates clauses in a fixed order: FROM, then WHERE, then GROUP BY, then HAVING, then window functions, then SELECT, then ORDER BY. When WHERE runs, rn has not been calculated yet — the column genuinely does not exist. HAVING does not help either, for the same reason.
The fix is to give the window a query of its own and filter the result:
WITH ranked AS (
SELECT product, category, sales,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) AS rn
FROM products
)
SELECT product, category, sales
FROM ranked
WHERE rn <= 3;
Memorise this shape. "Top N per group" is one of the most common real reporting requests there is, and this is the answer to all of them.
Can you use both together?
Yes, and it is more useful than it sounds. A window function applied to a grouped query operates on the grouped rows:
SELECT
region,
SUM(revenue) AS region_revenue,
SUM(revenue) * 100.0 / SUM(SUM(revenue)) OVER () AS pct_of_total
FROM sales
GROUP BY region;
The doubled SUM(SUM(revenue)) looks like a typo and is not. The inner SUM is the group aggregate; the outer one is the window running across all the grouped rows. Because windows are evaluated after GROUP BY, the window sees regions, not raw sales — which is exactly what a percentage-of-total needs.
What about performance?
As a rule, GROUP BY is cheaper. It reduces rows as it goes, while a window must retain every row and usually sort within each partition. If a summary is all you need, do not pay for a window to produce it.
That said, the comparison is often unfair, because the alternative to one window query is two queries and a join back to the detail — which is normally slower than the window and considerably harder to read. Compare the window against the query you would otherwise have written, not against the GROUP BY that answers a different question.
One practical note: PARTITION BY and ORDER BY inside a window benefit from an index in that same order, exactly as a regular ORDER BY does. On large tables that index is usually the difference between fast and unusable.
Coming to SQL from spreadsheets? XLsheetAI translates what you would have done with a pivot table into working SQL, explains the query line by line, and lets you practise the patterns until they are second nature.
Bottom line
Choose on the shape of the output. Fewer rows than you started with means GROUP BY. The same rows with more context means a window. Running totals, rankings, row-versus-group comparisons and previous-row lookups are windows only — and the moment you want to filter on one, reach for a CTE.
Related reading: Excel to SQL formula guide maps pivot tables and SUMIFS onto their SQL equivalents.
Frequently asked questions
What is the difference between a window function and GROUP BY?
GROUP BY collapses each group into a single output row, so the individual rows disappear from the result. A window function calculates the same aggregate but returns every original row with the group value attached alongside it. Five orders grouped by customer return one row; the same five orders with SUM() OVER (PARTITION BY customer) return all five.
Can I use a window function in a WHERE clause?
No. Window functions are evaluated after WHERE, GROUP BY and HAVING have already run, so the value does not exist when WHERE is applied. Wrap the query in a CTE or subquery and filter in the outer query instead. This is exactly the pattern used to return the top N rows per group with ROW_NUMBER.
Are window functions slower than GROUP BY?
Usually, yes, because a window has to keep every row rather than reducing them, and it often requires a sort per partition. If you only need summary rows, GROUP BY is both simpler and cheaper. Use a window when you genuinely need the detail rows retained; the cost buys you something GROUP BY cannot produce at all.
What does PARTITION BY do?
PARTITION BY is the window's equivalent of GROUP BY: it defines which rows belong to the same group for the calculation. SUM(x) OVER (PARTITION BY customer_id) restarts the sum for each customer. Omit PARTITION BY and the window covers the whole result set, which is how you compute each row's share of the overall total.
How do I get the top 3 rows per group in SQL?
Number the rows within each group using ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC), put that query in a CTE, then filter the outer query to rows where the number is 3 or less. GROUP BY with MAX can return the single best row per group, but it has no way to express a ranked three.
XLsheetAI