Part 4 · 10 chapters · ~20 min

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.

30

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.

code
-- 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 they sit in evaluation order
Step 5 of chapter 6: after 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.
group by versus window
collapse vs annotate
swipe the figure sideways, or tap expand for full screen
1/5
input
Five employees across two departments, each with a salary.
31

PARTITION BY and ORDER BY, decomposed

The OVER() clause has three independent parts, and confusion almost always comes from conflating them.

PartDecidesOmitted means
PARTITION BYWhich rows are in the window at allThe whole result set is one partition
ORDER BYThe order within the partitionNo order — and this changes the default frame
frameWhich of the ordered rows count for this rowSee the table below
code
-- 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)
the surprise that catches everyone once
Adding 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.
32

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.

ModeCounts byOn ties
ROWSPhysical row positionTakes exactly N rows, splitting ties arbitrarily
RANGEValue of the ORDER BY expressionIncludes every peer with the same value
GROUPSPeer groupsTakes N whole groups of ties

The default, stated exactly

code
-- 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
the RANGE default trap
The default is 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.
run it
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

code
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)
frames
ROWS vs RANGE on tied values
swipe the figure sideways, or tap expand for full screen
1/7
the data
Four rows ordered by date — and crucially, two of them share the same date.
33

Ranking functions

FunctionOn tiesExample: 10, 20, 20, 30
ROW_NUMBER()Arbitrary distinct numbers1, 2, 3, 4
RANK()Same rank, then gaps1, 2, 2, 4
DENSE_RANK()Same rank, no gaps1, 2, 2, 3
NTILE(n)Splits into n bucketsNTILE(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

code
-- 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).
choosing among the three
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.
34

LAG, LEAD, and the value functions

Reach into another row of the same partition without a self join.

code
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
code
-- 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;
the LAST_VALUE trap
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.
-- wrong: returns the current row every time LAST_VALUE(x) OVER (ORDER BY d) -- right: extend the frame to the end of the partition LAST_VALUE(x) OVER (ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) -- or simply reverse the sort and use FIRST_VALUE FIRST_VALUE(x) OVER (ORDER BY d DESC)
35

Running totals and moving averages

code
-- 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
a running balance is the classic ledger query
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.
36

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.

run it
-- 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-02

The 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:

code
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;
the pattern worth memorizing
A cumulative sum over a boundary flag produces a group id. Flag where a new group starts, then 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.
37

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.

code
-- 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:

code
-- Postgres only
SELECT DISTINCT ON (email) *
  FROM users
 ORDER BY email, updated_at DESC;
--   the ORDER BY must lead with the DISTINCT ON columns.
38

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).

code
-- 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);
reuse one window, sort once
Several window functions sharing an identical 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).

39

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.

code
-- 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.