MySQL Window Functions: ROW_NUMBER, RANK and More
Chat2DB TeamWindow functions arrived in MySQL 8.0 and changed how analytical queries are written. Before 8.0, ranking rows, computing running totals, or comparing a row with the previous one required correlated subqueries, self-joins, or fragile user-variable tricks. With MySQL window functions, a single SELECT can compute per-group rankings, moving averages, and inter-row deltas while still returning every row of the result set.
This guide walks through the full window function toolkit in MySQL 8.0+: the OVER clause, PARTITION BY, ORDER BY, frame clauses, ranking functions, LAG/LEAD, NTILE, named windows, the top-N-per-group pattern, and the performance characteristics you need to know.
Sample Data
All examples below run against this schema. Create it in any MySQL 8.0+ database (or paste it into a SQL console such as Chat2DB (opens in a new tab), which highlights window function syntax and lets you run each statement individually).
CREATE TABLE sales (
id INT PRIMARY KEY AUTO_INCREMENT,
employee VARCHAR(50) NOT NULL,
region VARCHAR(20) NOT NULL,
sale_date DATE NOT NULL,
amount DECIMAL(10,2) NOT NULL
);
INSERT INTO sales (employee, region, sale_date, amount) VALUES
('Alice', 'East', '2026-01-05', 1200.00),
('Alice', 'East', '2026-01-12', 800.00),
('Alice', 'East', '2026-02-03', 1500.00),
('Bob', 'East', '2026-01-08', 950.00),
('Bob', 'East', '2026-02-15', 950.00),
('Carol', 'West', '2026-01-10', 2100.00),
('Carol', 'West', '2026-01-25', 700.00),
('Dave', 'West', '2026-02-01', 1800.00),
('Dave', 'West', '2026-02-20', 400.00),
('Erin', 'West', '2026-02-22', 1800.00);The OVER Clause: How Window Functions Work
A window function computes a value for each row using a "window" of related rows, without collapsing them the way GROUP BY does. The window is defined by the OVER clause:
SELECT
employee,
region,
amount,
SUM(amount) OVER (PARTITION BY region) AS region_total,
ROUND(amount / SUM(amount) OVER (PARTITION BY region) * 100, 1) AS pct_of_region
FROM sales;Expected output (abridged):
employee | region | amount | region_total | pct_of_region
Alice | East | 1200.00 | 3900.00 | 30.8
Alice | East | 800.00 | 3900.00 | 20.5
Carol | West | 2100.00 | 6800.00 | 30.9
...Every row is preserved, but each also carries an aggregate computed over its partition. The three components of OVER are:
PARTITION BY— splits rows into independent groups (likeGROUP BY, but without collapsing rows). Omitting it treats the whole result set as one partition.ORDER BY— defines row order inside each partition, which matters for ranking,LAG/LEAD, and cumulative frames.- The frame clause (
ROWS BETWEEN ...) — restricts which rows within the partition feed the calculation.
ROW_NUMBER vs RANK vs DENSE_RANK
The three ranking functions differ only in how they treat ties. Bob has two sales of 950.00, and Dave and Erin both have a sale of 1800.00, so ties are easy to observe:
SELECT
employee,
amount,
ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num,
RANK() OVER (ORDER BY amount DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY amount DESC) AS dense_rnk
FROM sales
WHERE region = 'West';Expected output:
employee | amount | row_num | rnk | dense_rnk
Carol | 2100.00 | 1 | 1 | 1
Dave | 1800.00 | 2 | 2 | 2
Erin | 1800.00 | 3 | 2 | 2
Carol | 700.00 | 4 | 4 | 3
Dave | 400.00 | 5 | 5 | 4ROW_NUMBER()always produces unique, sequential numbers; ties are broken arbitrarily unless theORDER BYis deterministic.RANK()gives tied rows the same rank and then skips numbers (1, 2, 2, 4).DENSE_RANK()gives tied rows the same rank without gaps (1, 2, 2, 3).
Use ROW_NUMBER for pagination-style deduplication, RANK when gaps should reflect the number of tied competitors (sports-style ranking), and DENSE_RANK when you want contiguous rank values, for example "the second-highest distinct salary".
Top-N per Group
The most common practical pattern: the top sale per employee, or the top 2 sales per region. Window functions cannot appear in WHERE, so wrap the query in a derived table or CTE:
WITH ranked AS (
SELECT
employee,
region,
sale_date,
amount,
ROW_NUMBER() OVER (
PARTITION BY region
ORDER BY amount DESC, sale_date ASC
) AS rn
FROM sales
)
SELECT region, employee, sale_date, amount
FROM ranked
WHERE rn <= 2
ORDER BY region, rn;Expected output:
region | employee | sale_date | amount
East | Alice | 2026-02-03 | 1500.00
East | Alice | 2026-01-05 | 1200.00
West | Carol | 2026-01-10 | 2100.00
West | Dave | 2026-02-01 | 1800.00Note the tiebreaker (sale_date ASC) in the ORDER BY. Without it, which of the two 1800.00 rows becomes rank 2 is undefined and can change between executions. Always make the ordering deterministic when the row numbers drive filtering.
If ties should all survive, use RANK() instead of ROW_NUMBER() and accept more than N rows per group.
LAG and LEAD: Comparing Adjacent Rows
LAG(expr, offset, default) reads a value from a previous row; LEAD reads from a following row. This is the idiomatic way to compute deltas between consecutive events:
SELECT
employee,
sale_date,
amount,
LAG(amount, 1, 0) OVER (
PARTITION BY employee ORDER BY sale_date
) AS prev_amount,
amount - LAG(amount, 1, 0) OVER (
PARTITION BY employee ORDER BY sale_date
) AS change_vs_prev
FROM sales
WHERE employee = 'Alice';Expected output:
employee | sale_date | amount | prev_amount | change_vs_prev
Alice | 2026-01-05 | 1200.00 | 0.00 | 1200.00
Alice | 2026-01-12 | 800.00 | 1200.00 | -400.00
Alice | 2026-02-03 | 1500.00 | 800.00 | 700.00The third argument is the default when no previous row exists (otherwise you get NULL). Typical uses: day-over-day growth, session gap detection (compare timestamps and flag gaps larger than 30 minutes), and detecting duplicate consecutive readings in sensor data.
Frame Clauses: Running Totals and Moving Averages
When a window function has an ORDER BY inside OVER, MySQL applies a default frame of RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. That default makes cumulative sums work out of the box:
SELECT
sale_date,
amount,
SUM(amount) OVER (ORDER BY sale_date) AS running_total
FROM sales
WHERE region = 'East';For a moving average over a fixed number of rows, specify a ROWS frame explicitly:
SELECT
sale_date,
amount,
ROUND(AVG(amount) OVER (
ORDER BY sale_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 2) AS moving_avg_3
FROM sales
WHERE region = 'West';Expected output:
sale_date | amount | moving_avg_3
2026-01-10 | 2100.00 | 2100.00
2026-01-25 | 700.00 | 1400.00
2026-02-01 | 1800.00 | 1533.33
2026-02-20 | 400.00 | 966.67
2026-02-22 | 1800.00 | 1333.33Two subtleties worth memorizing:
ROWScounts physical rows;RANGEgroups peer rows with equalORDER BYvalues. With duplicate dates,RANGE ... CURRENT ROWincludes all rows sharing the current date, which can make a "running total" jump in steps. UseROWSwhen you want strictly row-by-row accumulation.- MySQL supports
ROWSandRANGEframes but not theGROUPSframe unit, andRANGEwith a numeric offset (e.g.RANGE BETWEEN INTERVAL 7 DAY PRECEDING AND CURRENT ROW) is not supported the way PostgreSQL 11+ supports it. In MySQL,RANGEoffsets are limited; for date-window logic, pre-bucket the data or use a self-join.
NTILE: Bucketing Rows
NTILE(n) distributes ordered rows into n roughly equal buckets — useful for quartiles or percentile bands:
SELECT
employee,
amount,
NTILE(4) OVER (ORDER BY amount DESC) AS quartile
FROM sales;With 10 rows and 4 buckets, MySQL assigns bucket sizes 3, 3, 2, 2 (earlier buckets get the extra rows). Quartile 1 contains the top three sale amounts.
Named Windows with the WINDOW Clause
When several functions share the same window definition, repeat yourself less with a named window:
SELECT
employee,
region,
amount,
ROW_NUMBER() OVER w AS rn,
RANK() OVER w AS rnk,
SUM(amount) OVER (w ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running
FROM sales
WINDOW w AS (PARTITION BY region ORDER BY amount DESC)
ORDER BY region, rn;The WINDOW clause sits between HAVING and ORDER BY. A named window can be extended inline — here the third function adds a frame clause on top of w. This keeps complex analytical queries readable and guarantees all functions use an identical partition/order definition.
Differences and Limits vs PostgreSQL
If you move between engines, note where MySQL's implementation is narrower:
- No
FILTERclause. PostgreSQL allowsCOUNT(*) FILTER (WHERE ...) OVER (...); in MySQL useSUM(CASE WHEN ... THEN 1 ELSE 0 END) OVER (...)instead. - No
GROUPSframe unit and noEXCLUDEframe options (EXCLUDE CURRENT ROW,EXCLUDE TIES). - Limited
RANGEoffsets. PostgreSQL supportsRANGE BETWEEN '7 days' PRECEDING ...; MySQL supportsRANGEmainly withUNBOUNDED/CURRENT ROWendpoints and numeric offsets only for numeric/temporalORDER BYin constrained forms. - No user-defined aggregates as window functions and no
WITHIN GROUPordered-set aggregates (percentile_contetc.). Emulate percentiles withNTILE,PERCENT_RANK(), orCUME_DIST(). IGNORE NULLSis not supported forLAG/LEAD/FIRST_VALUE/LAST_VALUE. A common workaround is a two-step query that carries the last non-NULL value forward usingMAXover a derived grouping column.
Everything shown above — ranking functions, LAG/LEAD, NTILE, frames, named windows — behaves consistently across MySQL 8.0+ and PostgreSQL, so the core patterns are portable.
Performance Tips
Window functions are evaluated after WHERE, GROUP BY, and HAVING, and each distinct window definition may require its own sort of the intermediate result. Practical guidance:
- Index to match
PARTITION BY+ORDER BY. ForOVER (PARTITION BY region ORDER BY sale_date), a composite index on(region, sale_date)lets the optimizer read rows already in window order and skip the filesort. CheckEXPLAIN FORMAT=TREE: you want to avoid aSortnode feeding theWindowstep when the table is large. - Filter early. Push
WHEREpredicates down before the window computation. Windowing 100k rows and discarding 99k afterwards wastes the sort; if the filter does not depend on the window value, apply it in the innermost query block. - Reuse window definitions. Functions sharing the same
WINDOWdefinition (or identicalOVERclauses) can share one sort pass. Ten functions over ten different orderings means up to ten sorts. - Watch
windowing_use_high_precision. MySQL disables some fast-path aggregation for moving frames when high precision is on (the default) forDOUBLEtypes; with fixed frames on large partitions,SUM/AVGrecomputation cost matters. PreferDECIMALwhere exactness is required anyway. - Beware of large partitions with
NTILE/PERCENT_RANK. They need the full partition materialized before producing the first row, so memory (or on-disk temporary tables, visible viaCreated_tmp_disk_tables) becomes the constraint.
When tuning, run EXPLAIN ANALYZE on the real query — it reports actual time spent in the Window aggregate and Sort iterators, which tells you immediately whether an index change removed the sort.
FAQ
Which MySQL version supports window functions? MySQL 8.0 and later. MySQL 5.7 has no window functions; the old user-variable emulation (@rownum := @rownum + 1) is unreliable and officially deprecated behavior.
Can I use a window function in WHERE or HAVING? No. Window functions are computed after those clauses. Wrap the query in a CTE or derived table and filter in the outer query, as in the top-N example above.
ROW_NUMBER or RANK for deduplication? ROW_NUMBER — you want exactly one row kept per partition (rn = 1), and RANK can return several rows tied at 1.
Do window functions replace GROUP BY? No. GROUP BY collapses rows into one per group; window functions keep every row. They compose well: you can apply a window function over the output of a GROUP BY in the same query, e.g. ranking monthly totals after aggregating by month.
