Window functions
The largest capability gap between SQL written before 2010 and after. A window function computes an aggregate without collapsing rows — each row keeps its identity and gains a value derived from a set of related rows. Problems that need a self-join, a correlated subquery or an application loop become one clause.
A window is a view per row
The mental model that makes everything else follow. GROUP BY
collapses N rows into one. A window function leaves N rows and attaches to each
one a value computed over its own window — a set of rows defined
relative to it.
-- GROUP BY: 3 departments → 3 rows. the employees are gone.
SELECT dept, AVG(salary) FROM emp GROUP BY dept;
-- window: 50 employees → 50 rows, each with its dept average alongside
SELECT name, dept, salary,
AVG(salary) OVER (PARTITION BY dept) AS dept_avg,
salary - AVG(salary) OVER (PARTITION BY dept) AS vs_avg
FROM emp;
-- comparing a row to its group is the canonical use.
-- without windows this needs a join to a grouped subquery.WHERE, GROUP BY and
HAVING, before SELECT completes. That placement
explains two rules — a window function can never appear in
WHERE or HAVING, and it operates on rows that have
already survived those filters.
To filter on a window result, wrap the query in a CTE or subquery so the window has been computed first.
PARTITION BY and ORDER BY, decomposed
The OVER() clause has three independent parts, and confusion
almost always comes from conflating them.
| Part | Decides | Omitted means |
|---|---|---|
PARTITION BY | Which rows are in the window at all | The whole result set is one partition |
ORDER BY | The order within the partition | No order — and this changes the default frame |
| frame | Which of the ordered rows count for this row | See the table below |
-- whole result set, all rows in the window SUM(x) OVER () -- one window per dept, all of that dept's rows SUM(x) OVER (PARTITION BY dept) -- one window per dept, ordered — and NOW the frame default -- makes this a RUNNING total, not a partition total. SUM(x) OVER (PARTITION BY dept ORDER BY hired_at)
ORDER BY inside OVER() silently changes what
the function computes. Without it you get the partition total; with it you get
a running total, because the default frame becomes "everything from the start
of the partition up to the current row".
That is not a quirk — it is the definition in chapter 32 — but it means
ORDER BY inside a window is never merely cosmetic.Frames: ROWS, RANGE, GROUPS
The frame defines which rows within the ordered partition contribute to the current row's result. It is the least understood part of window functions and the source of the most confusing wrong answers.
| Mode | Counts by | On ties |
|---|---|---|
ROWS | Physical row position | Takes exactly N rows, splitting ties arbitrarily |
RANGE | Value of the ORDER BY expression | Includes every peer with the same value |
GROUPS | Peer groups | Takes N whole groups of ties |
The default, stated exactly
-- with ORDER BY in OVER(), the default frame is: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- without ORDER BY, it is: RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
RANGE, not ROWS. Under
RANGE, "current row" means current row and all its peers —
every row with the same ORDER BY value.
So a running total over a column with duplicate values jumps: all tied rows show the same accumulated figure, including the whole tie group, rather than incrementing one row at a time. If you want row-by-row behaviour, you must write
ROWS explicitly. This is the single most common
window-function bug.CREATE TABLE t (d DATE, amt INT);
INSERT INTO t VALUES
('2026-01-01', 10),
('2026-01-02', 20),
('2026-01-02', 30), -- a tie on the ORDER BY column
('2026-01-03', 40);
SELECT d, amt,
SUM(amt) OVER (ORDER BY d) AS default_range,
SUM(amt) OVER (ORDER BY d
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS explicit_rows
FROM t ORDER BY d, amt;
-- d amt default_range explicit_rows
-- 2026-01-01 10 10 10
-- 2026-01-02 20 60 ← jumps 30
-- 2026-01-02 30 60 ← same! 60
-- 2026-01-03 40 100 100
--
-- RANGE included BOTH rows dated 01-02 for each of them.
-- If you wanted a row-by-row running total, you needed ROWS.Frame bounds
UNBOUNDED PRECEDING -- start of the partition n PRECEDING -- n rows/values back CURRENT ROW -- this row (and peers, under RANGE) n FOLLOWING -- n rows/values forward UNBOUNDED FOLLOWING -- end of the partition -- a centred 7-day moving average AVG(x) OVER (ORDER BY d ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING) -- RANGE with an INTERVAL: genuinely 7 days, not 7 rows -- (correct when days can be missing) AVG(x) OVER (ORDER BY d RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW)
Ranking functions
| Function | On ties | Example: 10, 20, 20, 30 |
|---|---|---|
ROW_NUMBER() | Arbitrary distinct numbers | 1, 2, 3, 4 |
RANK() | Same rank, then gaps | 1, 2, 2, 4 |
DENSE_RANK() | Same rank, no gaps | 1, 2, 2, 3 |
NTILE(n) | Splits into n buckets | NTILE(2) → 1,1,2,2 |
PERCENT_RANK() | (rank−1)/(rows−1) | 0, .33, .33, 1 |
CUME_DIST() | Cumulative distribution | .25, .75, .75, 1 |
Ranking functions ignore the frame entirely — they are defined over the whole ordered partition, so a frame clause on them is meaningless.
Top-N per group, the canonical pattern
-- the three highest-paid employees in each department
WITH ranked AS (
SELECT name, dept, salary,
ROW_NUMBER() OVER (PARTITION BY dept
ORDER BY salary DESC) AS rn
FROM emp
)
SELECT * FROM ranked WHERE rn <= 3;
-- the CTE is required: you cannot filter on rn in WHERE (ch 6).ROW_NUMBER when you need exactly N rows and do not care how ties
are broken — deduplication, pagination. RANK when ties should
genuinely share a position and consume the slots, as in competition standings.
DENSE_RANK when you want the top three distinct values
however many rows that is.
Note the interaction with chapter 19: for top-N per group on a large table,
LATERAL can stop after N per group, while
ROW_NUMBER ranks everything and then discards. Measure both.LAG, LEAD, and the value functions
Reach into another row of the same partition without a self join.
LAG(x, offset, default) -- a previous row's value LEAD(x, offset, default) -- a following row's value FIRST_VALUE(x) -- first in the FRAME LAST_VALUE(x) -- last in the FRAME ← see the trap NTH_VALUE(x, n) -- nth in the frame
-- day-over-day change, and percentage change
SELECT d, revenue,
LAG(revenue) OVER (ORDER BY d) AS prev,
revenue - LAG(revenue) OVER (ORDER BY d) AS delta,
ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY d))
/ NULLIF(LAG(revenue) OVER (ORDER BY d), 0), 1) AS pct
FROM daily;
-- NULLIF guards the division when the previous day was 0.
-- gap between consecutive events per user
SELECT user_id, event_at,
TIMESTAMPDIFF(SECOND,
LAG(event_at) OVER (PARTITION BY user_id ORDER BY event_at),
event_at) AS gap_s
FROM events;LAST_VALUE(x) OVER (ORDER BY d) returns the current row's
value, not the partition's last. Because the default frame ends at
CURRENT ROW, the last row of the frame is the current row.
Running totals and moving averages
-- running total — note the explicit ROWS (ch 32) SUM(amt) OVER (ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) -- running total per account, resetting each account SUM(amt) OVER (PARTITION BY account_id ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) -- trailing 7-row moving average AVG(amt) OVER (ORDER BY d ROWS 6 PRECEDING) -- share of the partition total: two windows, one query ROUND(100.0 * amt / SUM(amt) OVER (PARTITION BY region), 1) AS pct_of_region
SUM(amount) OVER (PARTITION BY account ORDER BY posted_at, id ROWS UNBOUNDED PRECEDING)
gives a running balance per account. The id tiebreaker matters:
without it, two entries with the same timestamp have undefined order, so the
balance column becomes non-deterministic between runs. Always make the window's
ORDER BY a total order.Gaps and islands
The canonical hard window problem: find runs of consecutive values, or the gaps between them. Once you know the trick it is three lines, and it appears constantly — streaks, sessions, uptime windows, free slots.
The difference trick
For consecutive integers, value − ROW_NUMBER() is
constant within a run and changes between runs. So it works as
a group key.
-- islands of consecutive days a user was active
WITH marked AS (
SELECT user_id, d,
DATE_SUB(d, INTERVAL
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY d)
DAY) AS grp
FROM activity
)
SELECT user_id,
MIN(d) AS streak_start,
MAX(d) AS streak_end,
COUNT(*) AS streak_days
FROM marked
GROUP BY user_id, grp
ORDER BY user_id, streak_start;
-- why it works:
-- d rn d - rn days
-- 01-01 1 2025-12-31 ┐ same → one island
-- 01-02 2 2025-12-31 ┘
-- 01-05 3 2026-01-02 ← changed → new island
-- 01-06 4 2026-01-02The gap-detection variant
When "consecutive" is not a fixed step — user sessions separated by more than
30 minutes of inactivity — use LAG plus a cumulative sum of a
boolean flag:
WITH flagged AS (
SELECT user_id, event_at,
CASE WHEN TIMESTAMPDIFF(MINUTE,
LAG(event_at) OVER (PARTITION BY user_id ORDER BY event_at),
event_at) > 30 THEN 1 ELSE 0 END AS new_session
FROM events
),
sessioned AS (
SELECT *, SUM(new_session) OVER (PARTITION BY user_id
ORDER BY event_at
ROWS UNBOUNDED PRECEDING) AS session_id
FROM flagged
)
SELECT user_id, session_id,
MIN(event_at) AS started, MAX(event_at) AS ended,
COUNT(*) AS events
FROM sessioned
GROUP BY user_id, session_id;SUM() OVER (ORDER BY ...) that flag — every
row in the same run gets the same number. This one idea solves sessionization,
streak detection, run-length encoding, and change-point grouping.Deduplication
Keeping one row per key — the most common real-world use of
ROW_NUMBER. Note that SELECT DISTINCT cannot do this,
because it deduplicates on the whole row, not on a key.
-- keep the most recent row per email
WITH ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY email
ORDER BY updated_at DESC, id DESC) AS rn
FROM users
)
SELECT * FROM ranked WHERE rn = 1;
-- and to actually DELETE the duplicates:
DELETE u FROM users u
JOIN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY email
ORDER BY updated_at DESC, id DESC) rn
FROM users
) x WHERE rn > 1
) d ON d.id = u.id;Postgres has a shorthand for the select case:
-- Postgres only SELECT DISTINCT ON (email) * FROM users ORDER BY email, updated_at DESC; -- the ORDER BY must lead with the DISTINCT ON columns.
Execution cost
A window function needs its partition sorted by
PARTITION BY then ORDER BY. If an index already
provides that order, the sort is free; otherwise it is a filesort over the
whole intermediate result (MySQL module, ch 84).
-- this window wants rows ordered by (user_id, created_at) ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) -- so this index removes the sort entirely CREATE INDEX idx ON events (user_id, created_at);
OVER() clause are
computed in a single pass over one sort. Different clauses mean different
sorts. So writing three functions over the same window is nearly free, while
three functions over three different windows costs three sorts — which is the
practical argument for the named windows in the next chapter.Versus the alternatives: a window function is almost always faster than a
correlated subquery evaluated per row, and usually faster than a self-join,
because it is one sorted pass rather than N lookups. The exception is
top-N-per-group with a small N and a supporting index, where
LATERAL can stop early (chapter 19).
Named windows
Repeating a long OVER() clause is error-prone — and the errors are
silent, because a subtly different clause is still valid SQL. The
WINDOW clause names it once.
-- repetitive and easy to get subtly wrong SELECT d, amt, SUM(amt) OVER (PARTITION BY acct ORDER BY d ROWS UNBOUNDED PRECEDING), AVG(amt) OVER (PARTITION BY acct ORDER BY d ROWS UNBOUNDED PRECEDING), ROW_NUMBER() OVER (PARTITION BY acct ORDER BY d) FROM ledger; -- named once, reused SELECT d, amt, SUM(amt) OVER w_run, AVG(amt) OVER w_run, ROW_NUMBER() OVER w_ord FROM ledger WINDOW w_ord AS (PARTITION BY acct ORDER BY d), w_run AS (w_ord ROWS UNBOUNDED PRECEDING); -- extends w_ord
The WINDOW clause sits between HAVING and
ORDER BY. Windows can extend other windows, adding a frame to an
existing partition and ordering — which both removes repetition and makes the
relationship between the two windows explicit.
Part 5 covers the other major SQL:1999 addition — common table expressions and recursion, which is where SQL stops being a query language and becomes Turing-complete.