SQL · Guides · October 30, 2026 · 13 min read
Top N per group in SQL: ROW_NUMBER, RANK, QUALIFY and LATERAL

To get the top N rows per group in SQL, number the rows inside each group with a window function and keep the first N: ROW_NUMBER() OVER (PARTITION BY group_col ORDER BY value DESC) in a subquery or CTE, then WHERE rn <= N outside it. PARTITION BY restarts the numbering for each group, and ORDER BY decides what “top” means. The real decisions are about ties: ROW_NUMBER always returns exactly N rows per group, RANK and DENSE_RANK return more when values are equal. The same tools find the second or Nth highest value.
All examples run on the airline database from the Nomto SQL course (104 airports in 17 time zones, 33,121 flights, about a million ticket segments), in PostgreSQL syntax on DuckDB, the engine of the Nomto SQL room. Every result table is the real output. For how window functions work in general, see SQL window functions.
Top N per group with ROW_NUMBER
The three busiest airports by departures in each time zone. First count departures per airport, then number the airports inside each time zone, then filter:
WITH d AS (
SELECT ap.timezone, f.departure_airport, COUNT(*) AS departures
FROM flights f
JOIN airports ap ON ap.airport_code = f.departure_airport
GROUP BY ap.timezone, f.departure_airport
), ranked AS (
SELECT timezone, departure_airport, departures,
ROW_NUMBER() OVER (
PARTITION BY timezone
ORDER BY departures DESC, departure_airport
) AS rn
FROM d
)
SELECT timezone, departure_airport, departures, rn
FROM ranked
WHERE rn <= 3
AND timezone IN ('Europe/Moscow', 'Asia/Yekaterinburg', 'Asia/Krasnoyarsk')
ORDER BY timezone, rn;
| timezone | departure_airport | departures | rn |
|---|---|---|---|
| Asia/Krasnoyarsk | KJA | 707 | 1 |
| Asia/Krasnoyarsk | TOF | 254 | 2 |
| Asia/Krasnoyarsk | ABA | 235 | 3 |
| Asia/Yekaterinburg | SVX | 689 | 1 |
| Asia/Yekaterinburg | PEE | 619 | 2 |
| Asia/Yekaterinburg | OVS | 549 | 3 |
| Europe/Moscow | DME | 3217 | 1 |
| Europe/Moscow | SVO | 2981 | 2 |
| Europe/Moscow | LED | 1900 | 3 |
(The time zone filter only shortens the output; without it you get up to three airports for each of the 17 zones.)
Three things in this query are not optional:
- The filter goes outside. Window functions are computed after
WHERE, soWHERE ROW_NUMBER() OVER (...) <= 3is an error. Computernin a CTE or subquery and filter in the next step. - A tie-breaker in
ORDER BY.departure_airportas the second sort key makes the numbering repeatable. Without it, rows with equal counts can swap places between runs. - Small groups just return fewer rows. Eight of the 17 zones have a single airport, so they contribute one row each, not three.
Note the grain: the window runs over the grouped result of d, one row per airport, not over individual flights. You can also put COUNT(*) straight into the window’s ORDER BY and skip the first CTE, as in the window functions guide.
Ties: ROW_NUMBER vs RANK vs DENSE_RANK
With real data, ties are common. Destinations from Kazan ranked by number of flights:
SELECT departure_airport, arrival_airport, COUNT(*) AS flights,
ROW_NUMBER() OVER w AS row_num,
RANK() OVER w AS rnk,
DENSE_RANK() OVER w AS dense_rnk
FROM flights
WHERE departure_airport = 'KZN'
GROUP BY departure_airport, arrival_airport
WINDOW w AS (PARTITION BY departure_airport ORDER BY COUNT(*) DESC)
ORDER BY flights DESC, arrival_airport;
| departure_airport | arrival_airport | flights | row_num | rnk | dense_rnk |
|---|---|---|---|---|---|
| KZN | ROV | 122 | 1 | 1 | 1 |
| KZN | DME | 61 | 2 | 2 | 2 |
| KZN | IKT | 61 | 6 | 2 | 2 |
| KZN | KVX | 61 | 3 | 2 | 2 |
| KZN | LED | 61 | 5 | 2 | 2 |
| KZN | MQF | 61 | 4 | 2 | 2 |
| KZN | MCX | 18 | 7 | 7 | 3 |
| KZN | MRV | 17 | 8 | 8 | 4 |
| KZN | PKV | 9 | 9 | 9 | 5 |
Five destinations share second place with 61 flights each. ROW_NUMBER hands out 2 to 6 among them in no meaningful order (there is no tie-breaker here on purpose, and another run may number them differently). The WINDOW w AS (...) clause defines the window once and reuses it; PostgreSQL, DuckDB and MySQL 8 support it, SQL Server since 2022.
Now ask for “the top 2 destinations” and count how many rows each function returns, for Kazan and for Novosibirsk:
WITH r AS (
SELECT departure_airport, arrival_airport, COUNT(*) AS flights,
ROW_NUMBER() OVER w AS row_num,
RANK() OVER w AS rnk,
DENSE_RANK() OVER w AS dense_rnk
FROM flights
WHERE departure_airport IN ('KZN', 'OVB')
GROUP BY departure_airport, arrival_airport
WINDOW w AS (PARTITION BY departure_airport ORDER BY COUNT(*) DESC)
)
SELECT departure_airport,
COUNT(*) FILTER (WHERE row_num <= 2) AS row_number_le_2,
COUNT(*) FILTER (WHERE rnk <= 2) AS rank_le_2,
COUNT(*) FILTER (WHERE dense_rnk <= 2) AS dense_rank_le_2
FROM r
GROUP BY departure_airport
ORDER BY departure_airport;
| departure_airport | row_number_le_2 | rank_le_2 | dense_rank_le_2 |
|---|---|---|---|
| KZN | 2 | 6 | 6 |
| OVB | 2 | 4 | 13 |
The same “top 2” returns anything from 2 to 13 rows. From Novosibirsk, four destinations tie for first with 122 flights, so RANK() <= 2 returns those four (the next rank is 5). DENSE_RANK() <= 2 means “the two highest values”, 122 and 61, and nine destinations have 61 flights.
Which to choose:
- Exactly N rows per group (a dashboard widget with three slots, a sample):
ROW_NUMBERwith a tie-breaker that makes sense to the reader, such as alphabetical or most recent. - Everyone who earned a top-N place, ties included (a leaderboard):
RANK. The group can exceed N. - All rows with the N highest values (the top 3 price points, however many tickets each):
DENSE_RANK.
If you use ROW_NUMBER and ties are possible, say how they were broken. A list that silently drops four of five equal routes is misleading.
QUALIFY: filtering window functions directly
Several engines have a QUALIFY clause that filters on a window function without a subquery, the way HAVING filters on an aggregate:
WITH d AS (
SELECT ap.timezone, f.departure_airport, COUNT(*) AS departures
FROM flights f
JOIN airports ap ON ap.airport_code = f.departure_airport
GROUP BY ap.timezone, f.departure_airport
)
SELECT timezone, departure_airport, departures
FROM d
QUALIFY ROW_NUMBER() OVER (PARTITION BY timezone ORDER BY departures DESC, departure_airport) <= 3
ORDER BY timezone, departures DESC
LIMIT 5;
| timezone | departure_airport | departures |
|---|---|---|
| Asia/Anadyr | DYR | 36 |
| Asia/Chita | HTA | 96 |
| Asia/Irkutsk | IKT | 366 |
| Asia/Irkutsk | UIK | 175 |
| Asia/Irkutsk | UUD | 71 |
QUALIFY works in DuckDB, Snowflake, BigQuery, Databricks and Teradata. PostgreSQL, MySQL, SQL Server and Oracle do not have it, so for portable code keep the CTE version. (SQL Server and Oracle users sometimes reach for TOP n WITH TIES or FETCH FIRST n ROWS WITH TIES, but those apply to the whole result, not per group.)
Top 1 per group: DISTINCT ON
When N is 1, PostgreSQL and DuckDB offer DISTINCT ON, which keeps the first row of each group according to ORDER BY. The busiest airport in each time zone:
WITH d AS (
SELECT ap.timezone, f.departure_airport, COUNT(*) AS departures
FROM flights f
JOIN airports ap ON ap.airport_code = f.departure_airport
GROUP BY ap.timezone, f.departure_airport
)
SELECT DISTINCT ON (timezone) timezone, departure_airport, departures
FROM d
ORDER BY timezone, departures DESC
LIMIT 5;
| timezone | departure_airport | departures |
|---|---|---|
| Asia/Anadyr | DYR | 36 |
| Asia/Chita | HTA | 96 |
| Asia/Irkutsk | IKT | 366 |
| Asia/Kamchatka | PKC | 26 |
| Asia/Krasnoyarsk | KJA | 707 |
ORDER BY must start with the DISTINCT ON columns, and the rest of it picks the winner. It is short and fast, but only returns one row per group and does not exist in MySQL or SQL Server. A tempting alternative, GROUP BY timezone with MAX(departures), gives the top value but not which airport it belongs to; joining back to find the airport returns several rows when two airports tie.
LATERAL: the top N for each row of another table
ROW_NUMBER ranks every row in every group and then throws most of them away. When the groups come from a small table and the rows from a big one, a LATERAL join is the alternative: for each row on the left, run a small ORDER BY ... LIMIT N subquery. The next three departures from each Moscow airport after noon on 20 August:
SELECT a.airport_code, nxt.flight_no, nxt.arrival_airport, nxt.scheduled_departure
FROM airports a
CROSS JOIN LATERAL (
SELECT f.flight_no, f.arrival_airport, f.scheduled_departure
FROM flights f
WHERE f.departure_airport = a.airport_code
AND f.scheduled_departure >= TIMESTAMP '2026-08-20 12:00'
ORDER BY f.scheduled_departure, f.flight_no
LIMIT 3
) AS nxt
WHERE a.city = 'Moscow'
ORDER BY a.airport_code, nxt.scheduled_departure;
| airport_code | flight_no | arrival_airport | scheduled_departure |
|---|---|---|---|
| DME | PG0196 | KGD | 2026-08-20 12:10:00 |
| DME | PG0384 | BZK | 2026-08-20 12:10:00 |
| DME | PG0402 | LED | 2026-08-20 12:25:00 |
| SVO | PG0066 | STW | 2026-08-20 12:15:00 |
| SVO | PG0461 | ULV | 2026-08-20 12:25:00 |
| SVO | PG0469 | LED | 2026-08-20 12:35:00 |
| VKO | PG0358 | SKX | 2026-08-20 12:05:00 |
| VKO | PG0233 | BZK | 2026-08-20 12:15:00 |
| VKO | PG0146 | OGZ | 2026-08-20 12:30:00 |
LATERAL lets the subquery refer to a.airport_code from the outer table, which an ordinary subquery in FROM cannot. In PostgreSQL, with an index on (departure_airport, scheduled_departure), each subquery reads just three index entries, which beats ranking millions of rows when you need “the latest 5 orders for each of these 20 customers”. Use LEFT JOIN LATERAL (...) ON TRUE to keep left rows with no matches. SQL Server writes the same thing as CROSS APPLY / OUTER APPLY with TOP 3; MySQL supports LATERAL from 8.0.14.
The second highest value, and the Nth
“Find the second highest value” is the most common form of the question. On economy segment prices, three approaches give two different answers:
SELECT amount
FROM ticket_flights
WHERE fare_conditions = 'Economy'
ORDER BY amount DESC
LIMIT 1 OFFSET 1;
| amount |
|---|
| 745.00 |
That is the top price again: 99 segments were sold at $745, and OFFSET 1 skips only one of them. Add DISTINCT and you get the second highest value:
SELECT DISTINCT amount
FROM ticket_flights
WHERE fare_conditions = 'Economy'
ORDER BY amount DESC
LIMIT 1 OFFSET 1;
| amount |
|---|
| 738.00 |
DENSE_RANK gives the same answer and keeps the rows, so you can see how many segments sit at that price:
SELECT amount, COUNT(*) AS segments
FROM (
SELECT amount, DENSE_RANK() OVER (ORDER BY amount DESC) AS dr
FROM ticket_flights
WHERE fare_conditions = 'Economy'
) r
WHERE dr = 2
GROUP BY amount;
| amount | segments |
|---|---|
| 738.00 | 15 |
For the Nth highest, change OFFSET 1 to OFFSET N-1, or dr = 2 to dr = N. The rule is the same as before: if “second” means the second distinct value, deduplicate (DISTINCT or DENSE_RANK); ROW_NUMBER and a plain OFFSET count rows, not values. SQL Server writes the offset as ORDER BY amount DESC OFFSET 1 ROWS FETCH NEXT 1 ROWS ONLY. Without any of these features, the old pattern is a correlated subquery: the value that has exactly N-1 distinct values above it, WHERE (SELECT COUNT(DISTINCT t2.amount) FROM t t2 WHERE t2.amount > t1.amount) = N - 1. It works everywhere but can be slow on large tables. For the version with MAX below MAX, see question 11 in SQL interview questions.
When there is no Nth value
Comfort class has only five distinct prices. Asking for the sixth returns an empty result, which a report or an interview grader may not expect. Wrapping the query in a scalar subquery turns “no rows” into a single NULL:
SELECT (
SELECT DISTINCT amount
FROM ticket_flights
WHERE fare_conditions = 'Comfort'
ORDER BY amount DESC
LIMIT 1 OFFSET 5
) AS sixth_highest;
| sixth_highest |
|---|
| null |
The Nth highest in each group
Combine both ideas: DENSE_RANK with PARTITION BY, filtered to = N. The second busiest airport in each time zone:
WITH d AS (
SELECT ap.timezone, f.departure_airport, COUNT(*) AS departures
FROM flights f
JOIN airports ap ON ap.airport_code = f.departure_airport
GROUP BY ap.timezone, f.departure_airport
), r AS (
SELECT *, DENSE_RANK() OVER (PARTITION BY timezone ORDER BY departures DESC) AS dr
FROM d
)
SELECT timezone, departure_airport AS second_busiest, departures
FROM r
WHERE dr = 2
ORDER BY departures DESC
LIMIT 5;
| timezone | second_busiest | departures |
|---|---|---|
| Europe/Moscow | SVO | 2981 |
| Asia/Yekaterinburg | PEE | 619 |
| Asia/Krasnoyarsk | TOF | 254 |
| Europe/Volgograd | RTW | 244 |
| Europe/Samara | ULY | 192 |
Only nine time zones appear in the full result: the eight with a single airport have no second place and drop out. If every group must be listed, start from the list of groups and LEFT JOIN the ranked rows with dr = 2 in the join condition.
Without window functions
Before window functions, top N per group was written with a correlated subquery: keep a row if fewer than N rows in the same group beat it.
WITH d AS (
SELECT ap.timezone, f.departure_airport, COUNT(*) AS departures
FROM flights f
JOIN airports ap ON ap.airport_code = f.departure_airport
GROUP BY ap.timezone, f.departure_airport
)
SELECT d1.timezone, d1.departure_airport, d1.departures
FROM d d1
WHERE (SELECT COUNT(*) FROM d d2
WHERE d2.timezone = d1.timezone
AND d2.departures > d1.departures) < 3
AND d1.timezone IN ('Europe/Moscow', 'Asia/Yekaterinburg', 'Asia/Krasnoyarsk')
ORDER BY d1.timezone, d1.departures DESC;
It returns the same nine rows as the first query. It behaves like RANK on ties (all tied rows pass), and it compares every row with every other row in its group, so it slows down quickly as groups grow. You will still meet it in old code and in MySQL 5.7, which has no window functions. Anywhere else, prefer ROW_NUMBER.
Which method to use
| need | method | where it works |
|---|---|---|
| exactly N rows per group | ROW_NUMBER() ... <= N in a CTE |
everywhere with window functions |
| top N with ties included | RANK() or DENSE_RANK() |
same |
| shorter syntax | QUALIFY |
DuckDB, Snowflake, BigQuery, Databricks |
| top 1 per group | DISTINCT ON |
PostgreSQL, DuckDB |
| few groups, big indexed table | LATERAL / CROSS APPLY |
PostgreSQL, DuckDB, MySQL 8.0.14+, SQL Server, Oracle |
| Nth highest value | DENSE_RANK() = N or DISTINCT ... OFFSET N-1 |
everywhere |
FAQ
How do I select the top N rows per group in SQL?
Number rows in each group with ROW_NUMBER() OVER (PARTITION BY group_col ORDER BY value DESC) in a CTE or subquery, then filter WHERE rn <= N in the outer query. Add a tie-breaker column to the ORDER BY so the result is repeatable.
What is the greatest-n-per-group problem?
It is the general name for “for each group, return the row (or rows) with the highest value”, such as the latest order per customer or the top seller per region. It is hard with plain GROUP BY because MAX() returns the value but not the rest of its row. Window functions, DISTINCT ON and LATERAL are the standard solutions.
How do I find the second highest value in SQL?
SELECT DISTINCT value FROM t ORDER BY value DESC LIMIT 1 OFFSET 1, or DENSE_RANK() OVER (ORDER BY value DESC) = 2 in a subquery. Both skip duplicates of the top value. Without DISTINCT, OFFSET 1 may return the top value again.
Should I use ROW_NUMBER or RANK for top N?
ROW_NUMBER when you need exactly N rows per group; it breaks ties arbitrarily unless you add a tie-breaker. RANK when tied rows should share a place, which can return more than N rows. DENSE_RANK when you want every row with one of the N highest values.
Why can’t I use ROW_NUMBER in a WHERE clause?
WHERE is evaluated before window functions are computed, so the row number does not exist yet at that point. Compute it in a subquery or CTE and filter outside, or use QUALIFY in engines that support it.