You already know how to write a GROUP BY. You can total up sales by month, count users by country, average an order value without breaking a sweat. Then someone asks a question that sounds just as simple: "For each customer, show every order they placed and how that order compares to their own average." Suddenly the query you know how to write doesn't fit. Grouping collapses your rows. You want the detail and the summary, side by side, in the same result.
This is the exact moment SQL window functions were invented for, and it's also the moment most self-taught developers go quiet, open a new browser tab, and start writing a subquery that joins the table to itself. It works. It's also slower, longer, and harder to read than the one-line alternative.
A window function lets you keep every row and still see the aggregate. Nothing collapses. Nothing gets thrown away.
Window functions have been in PostgreSQL, MySQL 8, SQL Server, Oracle, and SQLite for years now. If you're on a modern database, you already have them. This guide will get you from "I've heard of OVER()" to writing running totals, rankings, and period-over-period comparisons with confidence.
The one idea you actually need to understand
Here's the whole concept in a sentence: a regular aggregate squashes many rows into one; a window function computes the same aggregate but leaves your rows intact.
Look at the difference. Suppose you have an orders table:
-- The old way: 1 row per customer. Detail is gone.
SELECT customer_id, AVG(amount) AS avg_amount
FROM orders
GROUP BY customer_id;
-- The window way: every row survives, and each one carries its customer's average.
SELECT
order_id,
customer_id,
amount,
AVG(amount) OVER (PARTITION BY customer_id) AS customer_avg
FROM orders;The second query returns one row per order — all of them — and glues the customer's average onto each row like a sticky note. Now comparing an order to its customer's typical spend is trivial: amount - customer_avg. No self-join, no subquery, no temp table.
That word OVER is the switch. Put OVER (...) after an aggregate and you've told the database: don't collapse — just look around. The parentheses define which rows to look around at. That set of rows is called the window, and that's the entire vocabulary you need.
PARTITION BY: drawing the boundaries
PARTITION BY is GROUP BY's calmer cousin. It splits your rows into groups for the purpose of the calculation, but it doesn't merge them in the output.
SELECT
employee_id,
department,
salary,
AVG(salary) OVER (PARTITION BY department) AS dept_avg,
MAX(salary) OVER (PARTITION BY department) AS dept_max,
salary - AVG(salary) OVER (PARTITION BY department) AS diff_from_avg
FROM employees;One pass, three derived columns, every employee still listed. Try writing that with GROUP BY and you'll need a subquery and a join — roughly triple the SQL for the same answer.
Leave PARTITION BY out entirely and the window becomes the whole result set. AVG(salary) OVER () gives every row the company-wide average. That empty OVER () is not a typo; it's a deliberate "look at everything."
Where this pays off in real work: flagging outliers. An expense that's 3× the department average, a session that's 10× the median duration, a product whose return rate is far above its category's. All of these are "compare a row to its group" problems, and all of them are one window function.
Ranking: the family you'll use most
Three functions look similar and trip people up constantly. Learn the difference once and you'll never be confused again.
| Function | Ties get... | Gaps after ties? | Use it for |
|---|---|---|---|
ROW_NUMBER() | different numbers (arbitrary) | n/a | picking exactly one row per group |
RANK() | the same number | yes (1,1,3) | leaderboards, competition ranking |
DENSE_RANK() | the same number | no (1,1,2) | tier/grade assignment |
They all require ORDER BY inside the OVER(), because ranking is meaningless without a sort order:
SELECT
product_name,
category,
revenue,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) AS rn,
RANK() OVER (PARTITION BY category ORDER BY revenue DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY category ORDER BY revenue DESC) AS dense_rnk
FROM product_sales;Now the killer application. "Give me the top 3 products in each category" is a question plain SQL handles badly and window functions handle beautifully:
SELECT category, product_name, revenue
FROM (
SELECT
category,
product_name,
revenue,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) AS rn
FROM product_sales
) ranked
WHERE rn <= 3
ORDER BY category, rn;Note the wrapper. You cannot put a window function in a WHERE clause — the database computes windows after filtering, so the column doesn't exist yet at WHERE time. Wrapping it in a subquery (or a CTE, which reads better) is the standard workaround, and it's not a hack; it's just how the execution order works.
The same "top N per group" pattern solves an enormous number of everyday problems: the most recent order per customer (ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) then WHERE rn = 1), the latest status per ticket, the newest price per product. Once you see it, you'll spot it everywhere.
LAG and LEAD: comparing a row to its neighbors
Growth rates, churn deltas, "how long between this login and the last one" — these all mean comparing a row to the row before it. LAG() reaches backward; LEAD() reaches forward.
SELECT
month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month,
revenue - LAG(revenue) OVER (ORDER BY month) AS change,
ROUND(
100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
/ NULLIF(LAG(revenue) OVER (ORDER BY month), 0),
1
) AS pct_change
FROM monthly_revenue
ORDER BY month;If January is 100,000 and February is 112,000, February's row now reads: prev_month = 100000, change = 12000, pct_change = 12.0. The first row's prev_month is NULL — there's nothing before it — which is why the NULLIF guard matters. Division by zero and division by null are the two ways this query bites people in production.
LAG() takes optional extra arguments: LAG(revenue, 3, 0) means "the value 3 rows back, or 0 if there isn't one." That's how you get year-over-year comparisons on monthly data — LAG(revenue, 12).
A practical example. Say you're measuring user engagement and want the gap between consecutive sessions:
SELECT
user_id,
session_start,
session_start - LAG(session_start) OVER (
PARTITION BY user_id ORDER BY session_start
) AS gap_since_last
FROM sessions;Average that gap per user and you have a churn early-warning signal, built from one window function instead of a data pipeline.
Running totals and the frame clause
This is where window functions get their reputation for being confusing, and it's mostly a misunderstanding about one default.
When you add ORDER BY inside OVER(), the window quietly changes meaning. It stops being "all rows in the partition" and becomes "all rows in the partition from the start up to the current row." That default is what makes running totals work almost by accident:
SELECT
order_date,
amount,
SUM(amount) OVER (ORDER BY order_date) AS running_total
FROM orders
ORDER BY order_date;Each row's running_total includes everything up to and including itself. Cumulative revenue, cumulative signups, a bank-statement-style balance column — same shape every time.
If you want to control the window explicitly, you spell out a frame:
SELECT
order_date,
amount,
-- 7-day moving average (current row + 6 preceding)
AVG(amount) OVER (
ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_avg_7,
-- explicit running total (same as the default above)
SUM(amount) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM daily_orders;ROWS BETWEEN 6 PRECEDING AND CURRENT ROW is a sliding seven-row window. Moving averages — the thing that turns spiky daily charts into readable trend lines — are exactly this, and nothing more.
One trap worth knowing: ROWS counts physical rows, while RANGE counts values. If two rows share the same order_date, RANGE treats them as one step and ROWS doesn't. When in doubt, use ROWS — it does the obvious thing.
Keeping it readable: the WINDOW clause
Once you're writing three or four window functions in a query, the repetition gets ugly fast. Every major database supports naming a window and reusing it:
SELECT
employee_id,
department,
salary,
RANK() OVER w AS salary_rank,
AVG(salary) OVER w AS dept_avg,
MAX(salary) OVER w AS dept_max
FROM employees
WINDOW w AS (PARTITION BY department ORDER BY salary DESC);Define once, use everywhere. It's cleaner to read, and if you change the partitioning you change it in one place instead of four. Most people never learn this clause exists, and their queries are worse for it.
When not to reach for a window function
They're not free. A window function generally forces a sort, and on a large table that sort can be expensive. A few honest guidelines:
- Index the partition and order columns. A window over
PARTITION BY customer_id ORDER BY created_atruns dramatically faster with an index on(customer_id, created_at)— the database can walk the index instead of sorting from scratch. - Filter first, window second. If you only need this year's data, put that in a CTE and window over the smaller set. Windows are computed after
WHERE, so a tightWHEREclause is doing you a favor. - If a plain
GROUP BYanswers the question, use it. You only need windows when you want detail and aggregate together. Don't pay for a window you don't need. - Watch out on very large ranked scans. "Top 1 per group" over a hundred-million-row table is sometimes better served by a lateral join or a well-indexed
DISTINCT ON(PostgreSQL). Check the query plan rather than assuming.
The general rule: window functions almost always beat the self-join or correlated-subquery version they replace, both in speed and in readability. But they're not a reason to make a simple query complicated.
Start with one pattern
You don't need to memorize the whole toolkit. Learn the "top N per group" pattern first — ROW_NUMBER() inside a CTE, filtered in the outer query — because it's the one you'll reach for weekly. Then add LAG() when someone asks for month-over-month growth. Then add the running total. Three patterns cover the overwhelming majority of real analytical SQL.
Here's the summary worth keeping:
OVER()means "aggregate, but don't collapse my rows."PARTITION BY= groups.ORDER BYinsideOVER()= sequence (and it silently turns on a running frame).- Window functions can't live in
WHERE— wrap them in a CTE and filter outside. - Index the partition/order columns, and filter before you window.
The next time you find yourself writing a subquery that joins a table to itself just to get an average alongside a detail row, stop. That's a window function asking to be written. Once the pattern clicks, a whole class of "hard" SQL questions turns into a single, readable clause — and you get to spend your afternoon on something more interesting than a self-join.
Comments 0