SQL Window Functions: ROW_NUMBER, RANK, Running Totals, LAG and LEAD
Data AnalyticsLesson 1 of 46 min read
What you'll learn
- Explain how a window function differs from GROUP BY and read an OVER clause
- Number and rank rows with ROW_NUMBER, RANK and DENSE_RANK and predict how ties behave
- Build running totals and moving averages, and avoid the default-frame trap
- Compare rows with LAG and LEAD and pick the top row per group
On this page
A cycling club keeps one row per ride and wants three extra columns on the same listing: which ride this was for that rider, the rider's running distance so far, and how much longer or shorter the ride was than the previous one. GROUP BY cannot do it, because grouping collapses many rows into one. You would need a self-join or a loop in application code. A window function does the job in one pass: it computes a value from a set of related rows (the window) and attaches it to each row without removing any.
The shape of a window: OVER, PARTITION BY, ORDER BY
Any function followed by OVER (...) is evaluated as a window function. Inside the parentheses, PARTITION BY splits rows into independent groups (like GROUP BY, but nothing collapses) and ORDER BY sets the sequence inside each partition. Window functions are available in SQLite from version 3.25, in MySQL from 8.0 and in PostgreSQL and SQL Server for much longer, so the syntax below is portable except where noted. These queries ran against SQLite.
CREATE TABLE rides (
ride_id INTEGER PRIMARY KEY,
rider TEXT NOT NULL,
ride_date TEXT NOT NULL, -- ISO date
km INTEGER NOT NULL
);
INSERT INTO rides (rider, ride_date, km) VALUES
('Asha', '2025-06-01', 42),
('Asha', '2025-06-04', 30),
('Asha', '2025-06-04', 18),
('Asha', '2025-06-09', 55),
('Bruno', '2025-06-02', 30),
('Bruno', '2025-06-05', 64),
('Bruno', '2025-06-08', 30),
('Chen', '2025-06-01', 25),
('Chen', '2025-06-03', 64),
('Chen', '2025-06-10', 12);ROW_NUMBER() simply numbers the rows of each partition in the given order. Note the extra ride_id in the window's ORDER BY: Asha rode twice on 4 June, and without a tiebreaker the database may number those two rows in either order.
SELECT rider, ride_date, km,
ROW_NUMBER() OVER (
PARTITION BY rider
ORDER BY ride_date, ride_id
) AS nth_ride
FROM rides
ORDER BY rider, ride_date, ride_id;Output
rider | ride_date | km | nth_ride ------+------------+----+--------- Asha | 2025-06-01 | 42 | 1 Asha | 2025-06-04 | 30 | 2 Asha | 2025-06-04 | 18 | 3 Asha | 2025-06-09 | 55 | 4 Bruno | 2025-06-02 | 30 | 1 Bruno | 2025-06-05 | 64 | 2 Bruno | 2025-06-08 | 30 | 3 Chen | 2025-06-01 | 25 | 1 Chen | 2025-06-03 | 64 | 2 Chen | 2025-06-10 | 12 | 3
ROW_NUMBER, RANK and DENSE_RANK disagree about ties
All three assign positions, but they treat equal values differently. The next query ranks every ride by distance, longest first. Two rides cover 64 km, and three cover 30 km.
SELECT ride_id, rider, km,
ROW_NUMBER() OVER (ORDER BY km DESC, ride_id) AS row_num,
RANK() OVER (ORDER BY km DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY km DESC) AS dense_rnk
FROM rides
ORDER BY km DESC, ride_id;Output
ride_id | rider | km | row_num | rnk | dense_rnk
--------+-------+----+---------+-----+----------
6 | Bruno | 64 | 1 | 1 | 1
9 | Chen | 64 | 2 | 1 | 1
4 | Asha | 55 | 3 | 3 | 2
1 | Asha | 42 | 4 | 4 | 3
2 | Asha | 30 | 5 | 5 | 4
5 | Bruno | 30 | 6 | 5 | 4
7 | Bruno | 30 | 7 | 5 | 4
8 | Chen | 25 | 8 | 8 | 5
3 | Asha | 18 | 9 | 9 | 6
10 | Chen | 12 | 10 | 10 | 7| Function | On ties | Sequence for 64, 64, 55, 42 | Use it when |
|---|---|---|---|
| ROW_NUMBER | Gives each row a unique number; tied rows are ordered arbitrarily unless you add a tiebreaker | 1, 2, 3, 4 | You need exactly one row per position, such as picking one row per group |
| RANK | Ties share a rank and the next rank skips ahead | 1, 1, 3, 4 | Competition-style ranking where a tie for first means nobody is second |
| DENSE_RANK | Ties share a rank and the next rank follows without gaps | 1, 1, 2, 3 | You want the Nth distinct value, such as the third-longest distance |
Read the three right-hand columns of the output above. Both 64 km rides are rank 1, then RANK jumps to 3 for the 55 km ride, while DENSE_RANK continues at 2 and ROW_NUMBER just keeps counting. The three rides of 30 km all hold RANK 5, and the next ride (25 km) is RANK 8.
Running totals, and the default-frame trap
Add an aggregate such as SUM to a window with an ORDER BY and you get a running total. The subtlety is the frame, the slice of the partition that each row sees. If you give an ORDER BY but no frame, the standard default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, and RANGE treats rows with equal sort values as a single unit. Watch what happens to Asha's two rides on 4 June.
SELECT ride_date, km,
SUM(km) OVER (ORDER BY ride_date) AS running_default,
SUM(km) OVER (ORDER BY ride_date, ride_id
ROWS BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW) AS running_rows
FROM rides
WHERE rider = 'Asha'
ORDER BY ride_date, ride_id;Output
ride_date | km | running_default | running_rows -----------+----+-----------------+------------- 2025-06-01 | 42 | 42 | 42 2025-06-04 | 30 | 90 | 72 2025-06-04 | 18 | 90 | 90 2025-06-09 | 55 | 145 | 145
In running_default both 4 June rides show 90, because they are peers and each sees the other. The correct running total, with the tie broken by ride_id and a ROWS frame, goes 42, 72, 90, 145. The default is harmless when the order column is unique and misleading when it is not, so write the frame explicitly (and a unique tiebreaker) whenever the numbers matter. The same ROWS machinery gives moving averages: change the frame to ROWS BETWEEN 2 PRECEDING AND CURRENT ROW.
SELECT rider, ride_date, km,
ROUND(AVG(km) OVER (
PARTITION BY rider
ORDER BY ride_date, ride_id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 1) AS avg_last_3
FROM rides
ORDER BY rider, ride_date, ride_id;Output
rider | ride_date | km | avg_last_3 ------+------------+----+----------- Asha | 2025-06-01 | 42 | 42.0 Asha | 2025-06-04 | 30 | 36.0 Asha | 2025-06-04 | 18 | 30.0 Asha | 2025-06-09 | 55 | 34.3 Bruno | 2025-06-02 | 30 | 30.0 Bruno | 2025-06-05 | 64 | 47.0 Bruno | 2025-06-08 | 30 | 41.3 Chen | 2025-06-01 | 25 | 25.0 Chen | 2025-06-03 | 64 | 44.5 Chen | 2025-06-10 | 12 | 33.7
Each row averages itself and up to two earlier rides of the same rider. Early rows have fewer than three rides available, so their averages use fewer values, which is easy to forget when charting.
LAG and LEAD: looking at neighbouring rows
LAG(col) returns the value from the previous row in the window's order, and LEAD(col) the next one. The first and last rows of a partition have no neighbour, so they get NULL (you can supply a default as a second argument). When you reuse the same window several times, name it with a WINDOW clause instead of repeating it.
SELECT rider, ride_date, km,
LAG(km) OVER w AS prev_km,
km - LAG(km) OVER w AS change,
CAST(julianday(ride_date) - julianday(LAG(ride_date) OVER w) AS INTEGER) AS days_gap,
LEAD(ride_date) OVER w AS next_ride
FROM rides
WINDOW w AS (PARTITION BY rider ORDER BY ride_date, ride_id)
ORDER BY rider, ride_date, ride_id;Output
rider | ride_date | km | prev_km | change | days_gap | next_ride ------+------------+----+---------+--------+----------+----------- Asha | 2025-06-01 | 42 | NULL | NULL | NULL | 2025-06-04 Asha | 2025-06-04 | 30 | 42 | -12 | 3 | 2025-06-04 Asha | 2025-06-04 | 18 | 30 | -12 | 0 | 2025-06-09 Asha | 2025-06-09 | 55 | 18 | 37 | 5 | NULL Bruno | 2025-06-02 | 30 | NULL | NULL | NULL | 2025-06-05 Bruno | 2025-06-05 | 64 | 30 | 34 | 3 | 2025-06-08 Bruno | 2025-06-08 | 30 | 64 | -34 | 3 | NULL Chen | 2025-06-01 | 25 | NULL | NULL | NULL | 2025-06-03 Chen | 2025-06-03 | 64 | 25 | 39 | 2 | 2025-06-10 Chen | 2025-06-10 | 12 | 64 | -52 | 7 | NULL
Asha's second ride was 12 km shorter than the first and three days later; her third ride the same day gives a gap of 0. Each rider's first ride has NULLs for the previous-ride columns. The date arithmetic uses SQLite's julianday; in PostgreSQL you would subtract the dates directly, and in MySQL use DATEDIFF.
Top row per group: window in a subquery
Window functions are computed after WHERE, so you cannot filter on them in the same query level.
SELECT rider, km FROM rides WHERE ROW_NUMBER() OVER (PARTITION BY rider ORDER BY km DESC) = 1;Output
Error: misuse of window function ROW_NUMBER()
Wrap the ranking in a subquery (or a CTE, covered in the next lesson) and filter outside it. This is the standard recipe for "latest row per customer" or "top three per category".
SELECT rider, ride_date, km
FROM (
SELECT rider, ride_date, km,
ROW_NUMBER() OVER (PARTITION BY rider ORDER BY km DESC, ride_date) AS rn
FROM rides
) AS ranked
WHERE rn = 1
ORDER BY rider;Output
rider | ride_date | km ------+------------+--- Asha | 2025-06-09 | 55 Bruno | 2025-06-05 | 64 Chen | 2025-06-03 | 64
Chen and Bruno both rode 64 km once. Because the window orders by km DESC, ride_date, any tie between a rider's own equal distances would resolve to the earlier date, and ROW_NUMBER guarantees exactly one row per rider. If you wanted every tied row, use RANK() = 1 instead.
Common mistakes
- Filtering on a window function in WHERE. Error:
misuse of window function ROW_NUMBER()in SQLite, orwindow functions are not allowed in WHEREin PostgreSQL. Move the ranking into a subquery. - No tiebreaker in ROW_NUMBER or running totals. Symptom: results that change between runs, or running totals where tied rows show the same figure. Add a unique column to the ORDER BY.
- Relying on the default frame. Symptom: a running total that jumps for peer rows, or
LAST_VALUEreturning the current row instead of the last one. State the frame explicitly. - Confusing PARTITION BY with GROUP BY. A partition does not collapse rows. If your output has fewer rows than you wanted, you grouped; if it has more, you windowed.
- Forgetting the outer ORDER BY. The window's ORDER BY does not sort the final result. Add your own ORDER BY at the end.
How this shows up in real projects and interviews
Leaderboards, month-over-month change, running balances, de-duplication (keep the latest row per key) and sessionising events all use window functions, and they are a staple of data-analyst and backend interviews. The usual follow-ups are "what is the difference between RANK and DENSE_RANK", "find the second-highest per group" and "why did my running total repeat", all covered above. Window queries often sort the same data several ways, so indexes that match the PARTITION BY and ORDER BY columns can matter at scale; see Database Indexing. If you need to refresh grouping or joins first, start with SQL Fundamentals.
Try it yourself
Using the rides table, write one query that shows, for each ride, the rider's cumulative distance, the percentage of the rider's total distance that ride represents, and the distance difference to the rider's longest ride (use MAX(km) OVER (PARTITION BY rider)). Then write a second query that returns each rider's two longest rides using ROW_NUMBER in a subquery.
Show a hint
For the percentage, divide km by SUM(km) OVER (PARTITION BY rider) and multiply by 100.0 so the division is not an integer one. For the top two rides, keep rows where rn <= 2.
Key takeaways
- A window function adds a computed column to every row; unlike GROUP BY it never collapses rows.
- ROW_NUMBER is unique, RANK leaves gaps after ties, DENSE_RANK does not; always add a tiebreaker when you need stable results.
- Running totals depend on the frame: write ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW explicitly rather than trusting the RANGE default.
- You cannot filter on a window function in the same query's WHERE; rank in a subquery or CTE and filter outside.
Test yourself
A trick from this lesson's ride: OVER keeps the rows; GROUP BY folds them. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What is the difference between RANK, DENSE_RANK and ROW_NUMBER in SQL?
ROW_NUMBER gives every row a unique number. RANK gives tied rows the same rank and skips the following ranks, so 1, 1, 3. DENSE_RANK gives tied rows the same rank without gaps, so 1, 1, 2. Add a tiebreaker column to the ORDER BY when ties must be resolved deterministically.
What is the difference between GROUP BY and a window function?
GROUP BY collapses each group into one output row. A window function keeps every row and attaches a value computed over a related set of rows, so you can show a running total or a ranking next to the original columns.
How do I get the top N rows per group in SQL?
Number or rank the rows with ROW_NUMBER, RANK or DENSE_RANK using PARTITION BY the group and ORDER BY the measure, put that in a subquery or CTE, then filter the outer query on the rank being at most N. Window functions cannot be used directly in WHERE.