Ch. 11 · SQL & PostgreSQL

SQL Row Number, Rank and Dense Rank

row_number, rank and dense_rank differ only when values tie. Pick the right one for top-N per group, second-highest and leaderboard queries.

~6 min readintermediateupdated Oct 7, 2026

The three ranking window functions give identical output on data without duplicates, which is exactly why candidates mix them up. The moment two rows tie, they diverge, and each one answers a different question. Interviewers pick these questions because “top 3 per category” or “second-highest salary” sounds simple until you ask what happens with ties, and the answer reveals whether you thought about it.

All output below is from PostgreSQL 16.15. SQLite 3.51 returned identical rankings for the same data, since these functions are standard SQL.

Three answers to the same leaderboard

A game leaderboard with two regions:

CREATE TABLE scores (
  player text PRIMARY KEY, region text NOT NULL,
  points int NOT NULL, reached_at timestamptz NOT NULL
);
INSERT INTO scores VALUES
 ('ana','EU',980,'2026-09-01 10:00+00'), ('ben','EU',950,'2026-09-01 09:00+00'),
 ('cara','EU',950,'2026-09-01 11:00+00'), ('dev','EU',900,'2026-09-02 08:00+00'),
 ('eli','US',990,'2026-09-01 12:00+00'), ('fay','US',990,'2026-09-01 07:00+00'),
 ('gus','US',870,'2026-09-03 10:00+00');

SELECT player, points,
       row_number() OVER w AS row_number,
       rank()       OVER w AS rank,
       dense_rank() OVER w AS dense_rank
FROM scores
WHERE region = 'EU'
WINDOW w AS (ORDER BY points DESC)
ORDER BY points DESC, player;
sql
 player | points | row_number | rank | dense_rank
--------+--------+------------+------+------------
 ana    |    980 |          1 |    1 |          1
 ben    |    950 |          2 |    2 |          2
 cara   |    950 |          3 |    2 |          2
 dev    |    900 |          4 |    4 |          3
Text

Ben and Cara tie on 950. Read each column as a different rule:

  • row_number() numbers rows 1, 2, 3, 4 with no repeats. Ties are broken somehow, and “somehow” is the problem (next section).
  • rank() gives tied rows the same number, then skips: two players share 2nd, so the next player is 4th. This is how sports standings and Olympic medals work: two silvers, no bronze.
  • dense_rank() gives tied rows the same number and doesn’t skip: Dev is 3rd. It counts distinct values, so “3” means “third-highest score”, not “third player”.

A quick way to remember: rank answers “how many rows are strictly ahead of me, plus one?”; dense_rank answers “how many distinct values are ahead of me, plus one?”.

row_number with ties picks an arbitrary winner

Top player per region, using row_number:

SELECT region, player, points
FROM (
  SELECT *, row_number() OVER (PARTITION BY region ORDER BY points DESC) AS rn
  FROM scores
) s
WHERE rn = 1
ORDER BY region;
sql
 region | player | points
--------+--------+--------
 EU     | ana    |    980
 US     | eli    |    990
Text

Eli and Fay tied on 990 in the US. This run returned Eli. Nothing in the query says why. The ORDER BY inside the window only sorts by points, so the order among equal points depends on how the rows were read, and that can change after a vacuum, an update, a different plan or a parallel scan. A leaderboard where the winner flips between page loads is a real bug report, and it’s a hard one to reproduce.

If exactly one row per group is required, make the order total by adding tiebreakers that end in a unique column:

row_number() OVER (PARTITION BY region ORDER BY points DESC, reached_at, player)
sql
 region | player | points
--------+--------+--------
 EU     | ana    |    980
 US     | fay    |    990
Text

Fay reached 990 earlier, at 07:00, so she wins under a “first to reach it” rule, and player breaks any remaining tie. The tiebreaker is a business rule. Ask what it should be; don’t invent one silently.

If every tied row should appear, use rank() instead and filter r = 1, which returned both Eli and Fay. Choosing between those two is the first question to ask when someone says “top N”.

PostgreSQL’s DISTINCT ON is a shorthand for the one-row-per-group case and has the same tiebreaker requirement: SELECT DISTINCT ON (region) region, player, points FROM scores ORDER BY region, points DESC, reached_at also returned Ana and Fay.

The second-highest value, three ways

“Find the second-highest score in the EU” is an interview classic, and the tie between Ben and Cara is what makes it interesting.

-- dense_rank: second distinct value
SELECT DISTINCT points FROM (
  SELECT points, dense_rank() OVER (ORDER BY points DESC) AS d
  FROM scores WHERE region = 'EU'
) s WHERE d = 2;                            -- 950
sql
-- rank: rows in position 2, and position 3 doesn't exist
... rank() OVER (ORDER BY points DESC) AS r ... WHERE r = 2;   -- 950, 950
... WHERE r = 3;                                               -- (0 rows)
sql
-- no window function at all
SELECT max(points) FROM scores
WHERE region = 'EU'
  AND points < (SELECT max(points) FROM scores WHERE region = 'EU');   -- 950
sql

dense_rank is right when the question is about values (“second-highest salary”). rank is right when it’s about positions (“who finished second”). Asking for “third place” with rank returns nothing here, because two players share second and the next position is 4. The max(... < max(...)) version is fine for the single second-highest value and runs on databases without window functions, but it doesn’t generalise to “Nth”.

The traps: ORDER BY points DESC LIMIT 1 OFFSET 1 returns the second row, which is 950 here by luck. If Ana and someone else had tied on 980, it would return 980. And none of these return a row for a region with only one distinct score; if the question wants NULL in that case, wrap the result in a scalar subquery, which yields NULL when empty.

Top N with ties without a subquery

For a plain top-N (not per group), the SQL standard’s FETCH FIRST ... WITH TIES includes any rows that tie with the last one:

SELECT player, points FROM scores
WHERE region = 'EU'
ORDER BY points DESC
FETCH FIRST 2 ROWS WITH TIES;
sql
 player | points
--------+--------
 ana    |    980
 ben    |    950
 cara   |    950
Text

Asking for 2 returned 3 because Cara ties with Ben. LIMIT 2 would have cut Cara off, and which of Ben or Cara survives would be arbitrary. PostgreSQL has supported WITH TIES since version 13. For per-group top-N you still need a window function.

Filtering on a rank

You can’t write WHERE rank() OVER (...) = 1. Window functions are computed after WHERE, GROUP BY and HAVING, so the rank doesn’t exist yet when WHERE runs. PostgreSQL rejects it with window functions are not allowed in WHERE. Wrap the ranking in a subquery or CTE and filter outside, as every example here does. Some databases (Snowflake, BigQuery, DuckDB) add a QUALIFY clause for this; PostgreSQL 16 and 17 don’t have it.

For performance, a top-1-per-group query over a big table benefits from an index matching the window: (region, points DESC) lets PostgreSQL read each partition already sorted. When there are few groups and many rows each, a LATERAL subquery with ORDER BY points DESC LIMIT 1 per group can be much faster than ranking every row. The window functions guide covers partitions and frames more generally.

Interview practice: top three products per category

An e-commerce analyst asks for the three best-selling products in each category by units sold last month, “so we can feature them”. Some categories have ties around third place. Write the query and explain the choices you’d confirm with the analyst.

Answer

WITH sales AS (
  SELECT p.category_id, p.id AS product_id, p.name, sum(oi.qty) AS units
  FROM order_items oi
  JOIN orders o   ON o.id = oi.order_id
  JOIN products p ON p.id = oi.product_id
  WHERE o.created_at >= date '2026-09-01' AND o.created_at < date '2026-10-01'
  GROUP BY p.category_id, p.id, p.name
),
ranked AS (
  SELECT *, rank() OVER (PARTITION BY category_id ORDER BY units DESC) AS r
  FROM sales
)
SELECT category_id, product_id, name, units, r
FROM ranked
WHERE r <= 3
ORDER BY category_id, r, name;
sql

Aggregate first to one row per product, then rank, because ranking order lines would be meaningless. The key decision is the function. With rank, a four-way tie for third returns six products in that category, which is honest but may break a UI with three slots. With row_number and a stated tiebreaker (higher revenue, then newer product, then product_id), every category returns exactly three, deterministically. With dense_rank, “top three” means the top three distinct unit counts, which can return many products and is rarely what a featuring slot wants.

I’d ask the analyst whether the featuring slot is fixed at three. If it is, use row_number with an agreed tiebreaker. If not, rank reports ties faithfully. Either way, the tiebreaker or the tie behaviour should be documented with the query, since it decides which product gets featured.

Keep going

More in SQL & PostgreSQL

read ✓SQL & PostgreSQL · easy

SQL Window Functions Without Collapsing Rows

Window functions compute totals, running sums, LAG and moving averages next to each row. Learn PARTITION BY, frames, and the default-frame trap.

~7 min readread →
read ✓SQL & PostgreSQL · easy

DELETE vs TRUNCATE vs DROP in SQL (PostgreSQL)

DELETE removes chosen rows, TRUNCATE empties a table fast, DROP removes the table. Compare rollback, triggers, identity and locks in Postgres.

~7 min readread →
esc