← All articles

Window Functions Explained With the One Mental Model That Actually Sticks

Most people who are scared of window functions aren't actually confused by the syntax. They're confused because they're mentally filing OVER (PARTITION BY ...) under the same category as GROUP BY, and it isn't. It's closer to the opposite. Once that one distinction clicks, the syntax stops being the hard part.

GROUP BY collapses. Window functions don't.

GROUP BY takes many rows and reduces them to one row per group. That's the whole mechanism — it's fundamentally a collapsing operation. If you GROUP BY department and SUM(salary), you started with a thousand employee rows and you end with however many departments exist. The individual employees are gone from the output; all you have left is the aggregate.

SELECT department, SUM(salary) AS total_salary
FROM employees
GROUP BY department;
-- 1,000 rows in, 6 rows out (one per department)

A window function does the opposite. It computes an aggregate across a group of rows, but it attaches that value back onto every individual row instead of collapsing them:

SELECT
  name,
  department,
  salary,
  SUM(salary) OVER (PARTITION BY department) AS department_total
FROM employees;
-- 1,000 rows in, 1,000 rows out — every row now also carries its department's total

Same aggregate, same grouping logic — PARTITION BY department is doing conceptually the same job as GROUP BY department. The difference is entirely about what happens to the rows afterward. GROUP BY throws away the individual rows and keeps the aggregate. A window function keeps the individual rows and attaches the aggregate to each one. That's the entire concept. Everything else is variations on this.

Why "window" is actually the right word

The name isn't decorative. For each row, the database opens a "window" onto a set of related rows — defined by PARTITION BY — computes something over that window, and reports the result back on the current row, without ever collapsing anything. Every row gets its own window, and windows overlap freely: an employee in Engineering and an employee in Sales are each looking through a window that only contains their own department's rows, computed independently, side by side in the same result set.

This is also why window functions can do things GROUP BY structurally cannot — like ranking rows within a group, or computing a running total that depends on row order, because a window function has access to the current row's identity and its group's aggregate at the same time. GROUP BY never has both, because it's already thrown the individual row away by the time the aggregate exists.

The use case that makes this concrete: top N per group

"Get the top 3 highest-paid employees in each department" is the single most common real-world reason to reach for a window function, and it's the clearest illustration of why GROUP BY can't do this job. GROUP BY can tell you the maximum salary per department — one number per department. It cannot hand you back the top three rows, because rows don't survive a GROUP BY. There's nothing left to rank.

Before window functions existed in wide use, people solved this with a correlated subquery — for each employee, count how many other employees in the same department earn more, and keep the ones where that count is under 3. It works, but it's a subquery running once per row, which is slow, and it reads like a riddle.

With RANK() or ROW_NUMBER(), the same problem is direct:

SELECT name, department, salary
FROM (
  SELECT
    name,
    department,
    salary,
    ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rnk
  FROM employees
) ranked
WHERE rnk <= 3;

Read the OVER clause the way it's meant to be read: "for each department (PARTITION BY department), order employees by salary descending, and number them starting at 1." Every employee gets a rank relative to their own department only — Engineering's ranking and Sales's ranking are computed independently but simultaneously, in one pass. The outer query then just keeps rows numbered 1 through 3. No subquery-per-row, no self-join, no riddle.

RANK() and DENSE_RANK() are the same idea with different tie-handling: RANK() leaves gaps after ties (two people tied for 1st means the next rank is 3), DENSE_RANK() doesn't (the next rank is 2). Pick based on whether "3rd place" should exist when 1st and 2nd are tied.

Running totals are the same pattern, just ordered instead of ranked

SELECT
  order_date,
  amount,
  SUM(amount) OVER (ORDER BY order_date) AS running_total
FROM orders;

No PARTITION BY here means the whole table is one window — but the important part is ORDER BY inside the OVER clause. That tells the database, for each row, to sum every row from the start up through the current one in date order. Each row's window is different: row five's window is rows one through five, row six's window is rows one through six. That's what turns a flat SUM into a running total — the window itself grows as you move down the result set, and it's the same underlying mechanism as PARTITION BY, just ordered instead of grouped.

The model to keep

GROUP BY answers "what's the aggregate for this group," and the price is that individual rows disappear. A window function answers "what's the aggregate for this row's group, reported on this row, with nothing lost." Every window function — ranking, running totals, moving averages, lag/lead comparisons to the previous row — is that same idea, just choosing a different window shape via PARTITION BY and ORDER BY.

Once you stop reaching for GROUP BY out of habit and start asking "do I need to keep the individual rows," the choice between the two stops being a lookup and starts being obvious.

Top-N-per-group and running totals are both hands-on lessons in SQL Quest — you write the window function yourself against a live database instead of just reading about one. Free, no signup.