SQL · Guides · October 29, 2026 · 11 min read

SQL window functions: ROW_NUMBER, RANK, LAG and running totals

Rows of people with rank badges seen through a window frame, and a plane in the sky

A window function computes a value for each row from a set of related rows (the “window”) without collapsing those rows into one. GROUP BY turns 33,121 flights into six status rows; COUNT(*) OVER (PARTITION BY status) keeps all 33,121 flights and writes the count of each one’s status next to it. That single idea gives you rankings, top N per group, running totals, moving averages, shares of a total and comparisons with the previous row.

The syntax is always the same:

function(...) OVER (
  PARTITION BY ...   -- optional: restart the calculation for each group
  ORDER BY ...       -- optional: order rows inside the window
  ROWS/RANGE ...     -- optional: which rows around the current one to use
)

All examples run on the airline database of the Nomto SQL course (104 airports, 9 aircraft models, 33,121 flights in July–September 2026, 262,788 bookings; money in US dollars), in PostgreSQL syntax. Each output below is real, and you can run any query in the Nomto SQL room to check it.

OVER (): an aggregate next to every row

The simplest window is the whole result. Compare each model’s range with the fleet average:

SELECT model, range,
       ROUND(AVG(range) OVER ()) AS fleet_avg,
       range - ROUND(AVG(range) OVER ()) AS diff
FROM aircrafts
ORDER BY range DESC
LIMIT 5;
model range fleet_avg diff
Boeing 777-300 11100 5344 5756
Boeing 767-300 7900 5344 2556
Airbus A319-100 6700 5344 1356
Airbus A320-200 5700 5344 356
Airbus A321-200 5600 5344 256

Without a window you would need a subquery or a join to a one-row table to get fleet_avg onto each row.

Share of the total

Window functions run after GROUP BY, so you can wrap an aggregate in a window. SUM(COUNT(*)) OVER () is the sum of all group counts:

SELECT status, COUNT(*) AS flights,
       ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct
FROM flights
GROUP BY status
ORDER BY flights DESC;
status flights pct
Arrived 16707 50.4
Scheduled 15383 46.4
On Time 518 1.6
Cancelled 414 1.2
Departed 58 0.2
Delayed 41 0.1

The 100.0 matters: with integers, PostgreSQL would do integer division and most shares would come out as 0.

PARTITION BY: a separate window per group

PARTITION BY splits rows into groups and computes the window inside each one, like GROUP BY but without merging rows. Share of revenue by class within each aircraft model:

SELECT a.model, tf.fare_conditions,
       SUM(tf.amount) AS revenue,
       ROUND(100.0 * SUM(tf.amount) / SUM(SUM(tf.amount)) OVER (PARTITION BY a.model), 1) AS pct_of_model
FROM ticket_flights tf
JOIN flights f ON f.flight_id = tf.flight_id
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
WHERE a.aircraft_code IN ('773', 'SU9')
GROUP BY a.model, tf.fare_conditions
ORDER BY a.model, revenue DESC;
model fare_conditions revenue pct_of_model
Boeing 777-300 Economy 22398522.00 65.3
Boeing 777-300 Business 6252364.00 18.2
Boeing 777-300 Comfort 5661169.00 16.5
Sukhoi Superjet-100 Economy 35936340.00 70.3
Sukhoi Superjet-100 Business 15208507.00 29.7

The percentages add up to 100 within each model, not across the table.

ROW_NUMBER vs RANK vs DENSE_RANK

All three number rows in the order given by ORDER BY inside OVER. They differ only on ties:

Rank airports by the number of distinct destinations. The interesting part is around tenth place, where five airports tie with 9 destinations:

WITH d AS (
  SELECT departure_airport, COUNT(DISTINCT arrival_airport) AS destinations
  FROM flights
  GROUP BY departure_airport
), r AS (
  SELECT departure_airport, destinations,
         ROW_NUMBER() OVER (ORDER BY destinations DESC, departure_airport) AS row_num,
         RANK()       OVER (ORDER BY destinations DESC) AS rnk,
         DENSE_RANK() OVER (ORDER BY destinations DESC) AS dense_rnk
  FROM d
)
SELECT * FROM r
WHERE row_num BETWEEN 8 AND 15
ORDER BY row_num;
departure_airport destinations row_num rnk dense_rnk
ROV 11 8 8 8
KJA 10 9 9 9
IKT 9 10 10 10
KZN 9 11 10 10
PEE 9 12 10 10
SGC 9 13 10 10
TJM 9 14 10 10
ARH 8 15 15 11

After the five-way tie, RANK jumps to 15 (four places are “used up”), while DENSE_RANK continues with 11. The departure_airport tie-breaker in ROW_NUMBER makes its result repeatable; without it, IKT, KZN, PEE, SGC and TJM could come out in any order on a different run.

Which to use:

Top N per group

The classic use of ROW_NUMBER with PARTITION BY: the two most frequent destinations from each Moscow airport.

WITH r AS (
  SELECT departure_airport, arrival_airport, COUNT(*) AS flights,
         ROW_NUMBER() OVER (PARTITION BY departure_airport
                            ORDER BY COUNT(*) DESC, arrival_airport) AS rn
  FROM flights
  WHERE departure_airport IN ('SVO', 'DME', 'VKO')
  GROUP BY departure_airport, arrival_airport
)
SELECT departure_airport, arrival_airport, flights, rn
FROM r
WHERE rn <= 2
ORDER BY departure_airport, rn;
departure_airport arrival_airport flights rn
DME LED 244 1
DME BZK 183 2
SVO LED 305 1
SVO BZK 183 2
VKO BZK 183 1
VKO LED 183 2

Two things to notice:

  1. You cannot filter a window function in WHERE. WHERE runs before windows are computed, so WHERE ROW_NUMBER() OVER (...) <= 2 is an error. Compute the number in a CTE or subquery, then filter outside. (Some engines offer a QUALIFY clause for this; PostgreSQL does not.)
  2. Check for ties. From Vnukovo, BZK and LED both have 183 flights; ROW_NUMBER put BZK first only because of the alphabetical tie-breaker. If ties should both be shown, use RANK() ... <= 2; that can return more than two rows per group.

Deduplication: the latest row per key

The same pattern keeps one row per key. The last arrived departure of each flight number from Kazan:

WITH ranked AS (
  SELECT flight_no, arrival_airport, scheduled_departure,
         ROW_NUMBER() OVER (PARTITION BY flight_no ORDER BY scheduled_departure DESC) AS rn
  FROM flights
  WHERE departure_airport = 'KZN' AND status = 'Arrived'
)
SELECT flight_no, arrival_airport, scheduled_departure
FROM ranked
WHERE rn = 1
ORDER BY flight_no
LIMIT 5;
flight_no arrival_airport scheduled_departure
PG0039 KVX 2026-08-11 17:25:00
PG0203 DME 2026-08-10 17:45:00
PG0446 MRV 2026-08-11 11:25:00
PG0498 IKT 2026-08-11 10:15:00
PG0508 LED 2026-08-11 09:45:00

Running totals

Add ORDER BY inside OVER to an aggregate and it becomes cumulative. Daily bookings in the first week of August with a running total:

WITH daily AS (
  SELECT CAST(book_date AS date) AS day, COUNT(*) AS bookings
  FROM bookings
  WHERE book_date >= '2026-08-01' AND book_date < '2026-08-08'
  GROUP BY CAST(book_date AS date)
)
SELECT day, bookings,
       SUM(bookings) OVER (ORDER BY day) AS running_total,
       LAG(bookings) OVER (ORDER BY day) AS prev_day,
       bookings - LAG(bookings) OVER (ORDER BY day) AS change
FROM daily
ORDER BY day;
day bookings running_total prev_day change
2026-08-01 5746 5746 NULL NULL
2026-08-02 5761 11507 5746 15
2026-08-03 5665 17172 5761 -96
2026-08-04 5813 22985 5665 148
2026-08-05 5937 28922 5813 124
2026-08-06 6081 35003 5937 144
2026-08-07 6184 41187 6081 103

Add PARTITION BY to restart the running total per group, for example per airport or per month.

LAG and LEAD: the previous and next row

LAG(x) returns x from the previous row in the window order, LEAD(x) from the next one. The table above uses LAG for day-over-day change. On the first row there is no previous row, so the result is NULL; LAG(bookings, 1, 0) would return 0 instead, and LAG(bookings, 7) would look a week back.

LEAD answers “what happens next”. How often does flight PG0579 fly, and when is its last departure?

SELECT flight_no,
       CAST(scheduled_departure AS date) AS day,
       LEAD(CAST(scheduled_departure AS date)) OVER (ORDER BY scheduled_departure)
         - CAST(scheduled_departure AS date) AS days_to_next
FROM flights
WHERE flight_no = 'PG0579'
ORDER BY scheduled_departure DESC
LIMIT 3;
flight_no day days_to_next
PG0579 2026-09-09 NULL
PG0579 2026-09-02 7
PG0579 2026-08-26 7

A weekly flight; the last departure in the schedule has no next one. Note that the window’s ORDER BY (ascending) is independent of the query’s ORDER BY (descending): the window is computed first, then the output is sorted.

When comparing rows of many entities at once, always partition: LEAD(...) OVER (PARTITION BY flight_no ORDER BY scheduled_departure). Without it, the last departure of one flight would be compared with the first departure of the next flight number.

Window frames: ROWS vs RANGE

The frame defines which rows around the current one the function sees. Two things to remember.

The default frame. With ORDER BY and no explicit frame, the frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. RANGE treats all rows with the same ORDER BY value as one step, so tied rows get the same running total:

WITH d AS (
  SELECT departure_airport, COUNT(DISTINCT arrival_airport) AS destinations
  FROM flights
  GROUP BY departure_airport
), w AS (
  SELECT departure_airport, destinations,
         SUM(destinations) OVER (ORDER BY destinations DESC) AS range_total,
         SUM(destinations) OVER (ORDER BY destinations DESC, departure_airport
                                 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_total
  FROM d
)
SELECT * FROM w
WHERE destinations BETWEEN 9 AND 10
ORDER BY destinations DESC, departure_airport;
departure_airport destinations range_total rows_total
KJA 10 216 216
IKT 9 261 225
KZN 9 261 234
PEE 9 261 243
SGC 9 261 252
TJM 9 261 261

With RANGE, all five tied airports jump straight to 261. With ROWS, the total grows one row at a time. If your running total “steps” on equal timestamps, this is why. For a row-by-row running total, use ROWS and a unique ordering.

Moving averages. A frame like ROWS BETWEEN 2 PRECEDING AND CURRENT ROW is a 3-row moving window:

WITH daily AS (
  SELECT CAST(book_date AS date) AS day, COUNT(*) AS bookings
  FROM bookings
  WHERE book_date >= '2026-08-01' AND book_date < '2026-08-08'
  GROUP BY CAST(book_date AS date)
)
SELECT day, bookings,
       ROUND(AVG(bookings) OVER (ORDER BY day ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 1) AS avg_3d
FROM daily
ORDER BY day;
day bookings avg_3d
2026-08-01 5746 5746
2026-08-02 5761 5753.5
2026-08-03 5665 5724
2026-08-04 5813 5746.3
2026-08-05 5937 5805
2026-08-06 6081 5943.7
2026-08-07 6184 6067.3

The first two rows average over fewer than three days, because there is nothing before 1 August in this window. ROWS counts rows, not days: if a day were missing from daily, the “3-day” average would silently span four calendar days. Fill gaps with a date series first, or use RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW on a date column.

Where window functions fit in query order

Logically, windows are computed after FROM, WHERE, GROUP BY and HAVING, and before ORDER BY and LIMIT. That explains three rules:

For how WHERE, GROUP BY and HAVING interact, see GROUP BY vs HAVING. Ranking and “top N per group” are also among the most common SQL interview questions.

FAQ

What is the difference between a window function and GROUP BY?

GROUP BY returns one row per group. A window function returns every input row and adds a value computed over a group of rows. You can use both in one query: the window is applied to the grouped result.

What is the difference between RANK and DENSE_RANK?

Both give tied rows the same number. After a tie, RANK skips numbers (1, 2, 2, 4), DENSE_RANK does not (1, 2, 2, 3). In the airport example, five airports tie at 10th place; the next one is 15th by RANK and 11th by DENSE_RANK.

Why is my running total the same on several rows?

Because the default frame is RANGE, which treats rows with equal ORDER BY values as peers and includes all of them at once. Specify ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW and make the ordering unique.

Can I use a window function in WHERE?

No. Compute it in a CTE or subquery and filter in the outer query.

Does ROW_NUMBER need ORDER BY?

PostgreSQL allows ROW_NUMBER() OVER () but the numbering is then arbitrary. For any result you want to reproduce, give it an ORDER BY with a unique tie-breaker.

Also in Português (Brasil), Tiếng Việt, Español, 简体中文