Window functions in MySQL 8
Ranking, LAG and running totals without collapsing rows, the default frame that surprises people on ties, and why a window function cannot go in WHERE.
After this lesson you can
- Pick between ROW_NUMBER, RANK and DENSE_RANK for a tie
- Explain the default frame and when to write ROWS explicitly
- Filter on a window function's result through a CTE or derived table
- Compute a moving average over an explicit ROWS frame
MySQL gained window functions in 8.0. A window function computes a value
for each row from a set of related rows (the window) while keeping every
row in the output, which GROUP BY cannot do.
function(...) OVER (PARTITION BY ... ORDER BY ... frame)
PARTITION BY splits the rows into independent groups, ORDER BY orders
rows within each group, and the frame (covered below) chooses which rows of
the group an aggregate sees.
Ranking and neighbours
CREATE TABLE scores (player VARCHAR(10), team VARCHAR(5), points INT);INSERT INTO scores VALUES ('ann', 'red', 50), ('bob', 'red', 70), ('cy', 'red', 70), ('dee', 'blue', 40), ('eve', 'blue', 90);SELECT team, player, points, ROW_NUMBER() OVER (PARTITION BY team ORDER BY points DESC, player) AS rn, RANK() OVER w AS rnk, DENSE_RANK() OVER w AS dense, LAG(points) OVER (PARTITION BY team ORDER BY points DESC, player) AS prev_points, points - FIRST_VALUE(points) OVER w AS vs_leaderFROM scoresWINDOW w AS (PARTITION BY team ORDER BY points DESC)ORDER BY team, rn;ROW_NUMBER()numbers rows 1, 2, 3 with no ties. When two rows tie on theORDER BY, which gets 1 is not defined unless you add a tiebreaker; hereplayeris that tiebreaker.RANK()gives ties the same rank and then skips: 1, 1, 3.DENSE_RANK()gives ties the same rank without gaps: 1, 1, 2.LAG(x)andLEAD(x)read the previous or next row's value,NULLat the edge;FIRST_VALUE(x)reads the first row of the frame.
The WINDOW w AS (...) clause names a window definition so several
functions can share it.
The default frame
An aggregate such as SUM with an ORDER BY in its window uses the frame
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW unless you say
otherwise. RANGE counts peers, rows with the same ORDER BY value, as
part of the current row.
CREATE TABLE payments (id INT PRIMARY KEY, paid_on DATE, amount INT);INSERT INTO payments VALUES (1, '2026-03-01', 10), (2, '2026-03-02', 20), (3, '2026-03-02', 30), (4, '2026-03-05', 5);SELECT id, paid_on, amount, SUM(amount) OVER (ORDER BY paid_on) AS default_frame, SUM(amount) OVER (ORDER BY paid_on, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_frame, LAST_VALUE(amount) OVER (ORDER BY paid_on, id) AS last_default, LAST_VALUE(amount) OVER (ORDER BY paid_on, id ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_wholeFROM paymentsORDER BY id;The two payments on 2 March are peers, so the default frame gives both of
them the total through the end of that day, 60. With
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW and a unique ordering
(paid_on, id), the running total grows one row at a time. The same frame
rule is why LAST_VALUE(x) with only an ORDER BY returns the current row's
value rather than the last row of the partition: you must widen the frame to
UNBOUNDED FOLLOWING. A frame can also have a bounded start: ROWS BETWEEN 2 PRECEDING AND CURRENT ROW is the current row and the two before it in
the window's order, the frame of a moving average.
Filtering on a window function
Window functions are evaluated after WHERE, GROUP BY and HAVING, so
none of those clauses can refer to them, not even through an alias.
CREATE TABLE scores (player VARCHAR(10), team VARCHAR(5), points INT);INSERT INTO scores VALUES ('ann', 'red', 50), ('bob', 'red', 70), ('cy', 'red', 70), ('dee', 'blue', 40), ('eve', 'blue', 90);-- A helper that runs a statement and records the error instead of stopping.CREATE TABLE attempts (n INT AUTO_INCREMENT PRIMARY KEY, statement TEXT, outcome TEXT);CREATE PROCEDURE try_sql(IN stmt TEXT)BEGIN DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN GET DIAGNOSTICS CONDITION 1 @err_no = MYSQL_ERRNO, @err_msg = MESSAGE_TEXT; INSERT INTO attempts (statement, outcome) VALUES (stmt, CONCAT('error ', @err_no, ': ', REPLACE(@err_msg, CONCAT(DATABASE(), '.'), ''))); END; SET @sql = stmt; PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; INSERT INTO attempts (statement, outcome) VALUES (stmt, 'ok');END;CALL try_sql('SELECT player FROM scores WHERE RANK() OVER (ORDER BY points DESC) = 1');CALL try_sql('SELECT player, RANK() OVER (ORDER BY points DESC) AS r FROM scores HAVING r = 1');CALL try_sql('WITH ranked AS (SELECT player, RANK() OVER (ORDER BY points DESC) AS r FROM scores) SELECT player FROM ranked WHERE r = 1'); SELECT n, outcome FROM attempts ORDER BY n;Compute the window in a CTE or derived table and filter outside it, as the third attempt does. That is also the standard shape for "the latest row per group", and for the exercise, which filters on a moving average.
Common mistakes
ROW_NUMBER()without a tiebreaker. Two rows with equal sort values can swap numbers between runs; always end theORDER BYwith a unique column.- Running totals with the default frame on non-unique ordering. Peers share one total, as the 60 / 60 rows show.
WHERE rank_col = 1in the same query. Error 3593 or 3594; wrap it.- The "join back to MAX(date)" approach for the latest row. It returns two rows when two orders share the latest date.
What interviewers ask
- Difference between
RANKandDENSE_RANK? Both share a rank on ties;RANKleaves a gap after them,DENSE_RANKdoes not. - Why does my running total jump on rows with the same date? The default
RANGEframe includes peers; useROWSand a unique ordering. - How do you get each customer's latest order?
ROW_NUMBER()partitioned by customer, ordered by date descending plus a tiebreaker, then filter= 1outside.
Exercise · Pro
Days above a 3-day moving average of 100
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade