Lesson 06 / 20
Ranking Functions
The tie behavior of row number, rank, and dense rank, tile and percentile distribution functions, selecting the top N within a partition, and deduplication with row number.
Contents
The previous lesson ran computations over a window but did not give the rows a rank. Questions like “the three members who borrowed the most” first want a rank number, and right after that call for a decision: should two members with an equal count get the same rank, or different ones? This question has three possible answers, and SQL defines all three as separate functions.
This lesson places the ranking functions side by side on the same data, shows their differences, and establishes the condition needed for a ranking to be deterministic.
Three Functions, Three Tie Decisions
All three functions order the window with ORDER BY and give every row an integer. Where
they part ways is what happens with rows that are equal in value on the sort key:
ROW_NUMBER: does not recognize ties. It gives every row a different number, proceeding 1, 2, 3.RANK: gives ties the same rank, then skips. If two rows are second, the next one is fourth.DENSE_RANK: gives ties the same rank, without skipping. If two rows are second, the next one is third.
All three can be written in the same query; the shared window is defined once in a
WINDOW clause:
sqlite3 -box -header <<'SQL' CREATE TABLE member(id INTEGER PRIMARY KEY, name TEXT, branch TEXT); CREATE TABLE loan(id INTEGER PRIMARY KEY, book_id INT, member_id INT, pickup TEXT, returned TEXT); INSERT INTO member VALUES (1,'Alice','Kadikoy'),(2,'Ben','Kadikoy'),(3,'Clara','Uskudar'), (4,'Derek','Uskudar'),(5,'Evan','Besiktas'),(6,'Fiona','Besiktas'), (7,'Grace','Kadikoy'),(8,'Hannah','Uskudar'); INSERT INTO loan VALUES (1,1,1,'2024-03-01','2024-03-15'),(2,3,1,'2024-03-01','2024-03-20'), (3,5,1,'2024-03-04','2024-03-18'),(4,7,1,'2024-03-11',NULL), (5,9,1,'2024-03-18','2024-03-29'),(6,2,2,'2024-03-01','2024-03-12'), (7,4,2,'2024-03-06','2024-03-25'),(8,6,2,'2024-03-11','2024-03-19'), (9,8,2,'2024-03-21',NULL),(10,1,3,'2024-03-04','2024-03-10'), (11,3,3,'2024-03-06','2024-03-27'),(12,10,3,'2024-03-13','2024-03-22'), (13,2,3,'2024-03-25',NULL),(14,5,4,'2024-03-04','2024-03-09'), (15,7,4,'2024-03-13','2024-03-26'),(16,4,4,'2024-03-20',NULL), (17,6,5,'2024-03-06','2024-03-14'),(18,9,5,'2024-03-11','2024-03-23'), (19,1,5,'2024-03-25','2024-03-28'),(20,8,6,'2024-03-04','2024-03-17'), (21,10,6,'2024-03-13','2024-03-21'),(22,3,6,'2024-03-20',NULL), (23,2,7,'2024-03-11','2024-03-16'),(24,5,7,'2024-03-18','2024-03-24'), (25,4,8,'2024-03-13','2024-03-24'); WITH count AS ( SELECT m.name, m.branch, COUNT(l.id) AS count FROM member m LEFT JOIN loan l ON l.member_id = m.id GROUP BY m.id, m.name, m.branch ) SELECT name, count, ROW_NUMBER() OVER s AS row_num, RANK() OVER s AS rank, DENSE_RANK() OVER s AS dense_rank FROM count WINDOW s AS (ORDER BY count DESC) ORDER BY count DESC, name; SQL
┌────────┬───────┬─────────┬──────┬────────────┐ │ name │ count │ row_num │ rank │ dense_rank │ ├────────┼───────┼─────────┼──────┼────────────┤ │ Alice │ 5 │ 1 │ 1 │ 1 │ │ Ben │ 4 │ 2 │ 2 │ 2 │ │ Clara │ 4 │ 3 │ 2 │ 2 │ │ Derek │ 3 │ 4 │ 4 │ 3 │ │ Evan │ 3 │ 5 │ 4 │ 3 │ │ Fiona │ 3 │ 6 │ 4 │ 3 │ │ Grace │ 2 │ 7 │ 7 │ 4 │ │ Hannah │ 1 │ 8 │ 8 │ 5 │ └────────┴───────┴─────────┴──────┴────────────┘
Two members have four loans each. RANK gave both of them 2, and gave the next member 4,
not 3: third place was counted as consumed. DENSE_RANK gave both of them 2 and gave the
next one 3; the rank numbers advanced with no gap. ROW_NUMBER did not see the tie at all
and distributed 2 and 3 arbitrarily.
These three columns correspond to three separate questions. RANK is “how many members
got more than me” with one added — the habit of competition rankings. DENSE_RANK
answers “how many distinct values are greater than mine” and is suited for counting
levels. ROW_NUMBER produces not a rank but an identity: it is used to tell rows
apart from each other.
In the last row, RANK gives Hannah 8, DENSE_RANK gives 5. The difference between the
two numbers is the combined effect of the ties: eight members, but five distinct values.
The Indeterminacy of a Tie
In the ROW_NUMBER column, Ben got 2 and Clara got 3. This distribution does not
follow from how the query is written: since both have a count value of 4,
ORDER BY count DESC does not separate them. Which number went to which was the engine’s
decision.
This is a situation to be avoided. The same query, under a different plan, in a different
version, or with the table read in a different order, can swap the numbers. If
ROW_NUMBER is being used to paginate or deduplicate a result set, this swap turns into a
silent bug: a record appears twice across pages, and another one never appears at all.
The rule is clear: in a window where ROW_NUMBER is used, the ORDER BY key must
determine the rows uniquely. If the key does not do this, a unique column, such as a
primary key, is added to the end — the phrasing ORDER BY count DESC, name removes the
indeterminacy shown above. RANK and DENSE_RANK are not affected by this problem,
because they already give tied rows the same value.
Tile and Percentile Distribution
The other members of the ranking family give a row’s position in the ordering as a
proportion. NTILE(n) splits the partition into n parts as evenly as possible;
PERCENT_RANK gives the rank’s counterpart between zero and one, and CUME_DIST gives
“the proportion of rows up to and including this one”:
sqlite3 -box -header <<'SQL' CREATE TABLE member(id INTEGER PRIMARY KEY, name TEXT, branch TEXT); CREATE TABLE loan(id INTEGER PRIMARY KEY, book_id INT, member_id INT, pickup TEXT, returned TEXT); INSERT INTO member VALUES (1,'Alice','Kadikoy'),(2,'Ben','Kadikoy'),(3,'Clara','Uskudar'), (4,'Derek','Uskudar'),(5,'Evan','Besiktas'),(6,'Fiona','Besiktas'), (7,'Grace','Kadikoy'),(8,'Hannah','Uskudar'); INSERT INTO loan VALUES (1,1,1,'2024-03-01','2024-03-15'),(2,3,1,'2024-03-01','2024-03-20'), (3,5,1,'2024-03-04','2024-03-18'),(4,7,1,'2024-03-11',NULL), (5,9,1,'2024-03-18','2024-03-29'),(6,2,2,'2024-03-01','2024-03-12'), (7,4,2,'2024-03-06','2024-03-25'),(8,6,2,'2024-03-11','2024-03-19'), (9,8,2,'2024-03-21',NULL),(10,1,3,'2024-03-04','2024-03-10'), (11,3,3,'2024-03-06','2024-03-27'),(12,10,3,'2024-03-13','2024-03-22'), (13,2,3,'2024-03-25',NULL),(14,5,4,'2024-03-04','2024-03-09'), (15,7,4,'2024-03-13','2024-03-26'),(16,4,4,'2024-03-20',NULL), (17,6,5,'2024-03-06','2024-03-14'),(18,9,5,'2024-03-11','2024-03-23'), (19,1,5,'2024-03-25','2024-03-28'),(20,8,6,'2024-03-04','2024-03-17'), (21,10,6,'2024-03-13','2024-03-21'),(22,3,6,'2024-03-20',NULL), (23,2,7,'2024-03-11','2024-03-16'),(24,5,7,'2024-03-18','2024-03-24'), (25,4,8,'2024-03-13','2024-03-24'); WITH count AS ( SELECT m.name, COUNT(l.id) AS count FROM member m LEFT JOIN loan l ON l.member_id = m.id GROUP BY m.id, m.name ) SELECT name, count, NTILE(4) OVER s AS tile, ROUND(PERCENT_RANK() OVER s, 2) AS percent_rank, ROUND(CUME_DIST() OVER s, 2) AS cume_dist FROM count WINDOW s AS (ORDER BY count DESC) ORDER BY count DESC, name; SQL
┌────────┬───────┬──────┬──────────────┬───────────┐ │ name │ count │ tile │ percent_rank │ cume_dist │ ├────────┼───────┼──────┼──────────────┼───────────┤ │ Alice │ 5 │ 1 │ 0.0 │ 0.13 │ │ Ben │ 4 │ 1 │ 0.14 │ 0.38 │ │ Clara │ 4 │ 2 │ 0.14 │ 0.38 │ │ Derek │ 3 │ 2 │ 0.43 │ 0.75 │ │ Evan │ 3 │ 3 │ 0.43 │ 0.75 │ │ Fiona │ 3 │ 3 │ 0.43 │ 0.75 │ │ Grace │ 2 │ 4 │ 0.86 │ 0.88 │ │ Hannah │ 1 │ 4 │ 1.0 │ 1.0 │ └────────┴───────┴──────┴──────────────┴───────────┘
The row that stands out is Ben and Clara: they have the same count value, the same
percentile rank, and the same cumulative distribution, but they fell into different
tiles. NTILE does not consider ties; it splits rows by number, and if a partition
boundary falls in the middle of a set of peers, it splits them apart. This is why the tile
number is, for rows of equal value, as indeterminate as ROW_NUMBER.
PERCENT_RANK always gives the first row 0, CUME_DIST always gives the last row 1. The
difference between the two is in their definitions: percentile rank measures the
proportion “how many rows are ahead of me,” cumulative distribution measures the
proportion “of me and everyone not greater than me.”
Top N Within a Partition
The most common use of ranking functions is selecting the best of each partition. As
noted in the previous lesson, a window function’s result cannot be used in the same
query’s WHERE clause; the ranking is done in a common table expression and the
filtering happens on the outside:
sqlite3 -box -header <<'SQL' CREATE TABLE member(id INTEGER PRIMARY KEY, name TEXT, branch TEXT); CREATE TABLE loan(id INTEGER PRIMARY KEY, book_id INT, member_id INT, pickup TEXT, returned TEXT); INSERT INTO member VALUES (1,'Alice','Kadikoy'),(2,'Ben','Kadikoy'),(3,'Clara','Uskudar'), (4,'Derek','Uskudar'),(5,'Evan','Besiktas'),(6,'Fiona','Besiktas'), (7,'Grace','Kadikoy'),(8,'Hannah','Uskudar'); INSERT INTO loan VALUES (1,1,1,'2024-03-01','2024-03-15'),(2,3,1,'2024-03-01','2024-03-20'), (3,5,1,'2024-03-04','2024-03-18'),(4,7,1,'2024-03-11',NULL), (5,9,1,'2024-03-18','2024-03-29'),(6,2,2,'2024-03-01','2024-03-12'), (7,4,2,'2024-03-06','2024-03-25'),(8,6,2,'2024-03-11','2024-03-19'), (9,8,2,'2024-03-21',NULL),(10,1,3,'2024-03-04','2024-03-10'), (11,3,3,'2024-03-06','2024-03-27'),(12,10,3,'2024-03-13','2024-03-22'), (13,2,3,'2024-03-25',NULL),(14,5,4,'2024-03-04','2024-03-09'), (15,7,4,'2024-03-13','2024-03-26'),(16,4,4,'2024-03-20',NULL), (17,6,5,'2024-03-06','2024-03-14'),(18,9,5,'2024-03-11','2024-03-23'), (19,1,5,'2024-03-25','2024-03-28'),(20,8,6,'2024-03-04','2024-03-17'), (21,10,6,'2024-03-13','2024-03-21'),(22,3,6,'2024-03-20',NULL), (23,2,7,'2024-03-11','2024-03-16'),(24,5,7,'2024-03-18','2024-03-24'), (25,4,8,'2024-03-13','2024-03-24'); WITH count AS ( SELECT m.name, m.branch, COUNT(l.id) AS count FROM member m LEFT JOIN loan l ON l.member_id = m.id GROUP BY m.id, m.name, m.branch ), ranked AS ( SELECT branch, name, count, RANK() OVER b AS rank, ROW_NUMBER() OVER b AS row_num FROM count WINDOW b AS (PARTITION BY branch ORDER BY count DESC) ) SELECT branch, name, count, rank, row_num FROM ranked WHERE rank <= 1 ORDER BY branch, name; SQL
┌──────────┬───────┬───────┬──────┬─────────┐ │ branch │ name │ count │ rank │ row_num │ ├──────────┼───────┼───────┼──────┼─────────┤ │ Besiktas │ Evan │ 3 │ 1 │ 1 │ │ Besiktas │ Fiona │ 3 │ 1 │ 2 │ │ Kadikoy │ Alice │ 5 │ 1 │ 1 │ │ Uskudar │ Clara │ 4 │ 1 │ 1 │ └──────────┴───────┴───────┴──────┴─────────┘
Besiktas gave two rows, the others gave one each. The cause is the choice of function: with
RANK, filtering keeps all the tied rows; with WHERE row_num <= 1, Fiona would have
dropped out, and which one dropped would have been indeterminate.
The choice depends on what the question wants. If “each branch’s top-borrowing member”
wants two names on a tie, RANK is right; if it wants exactly one row, ROW_NUMBER is
right — and in the second case an additional tie-breaking column also has to be specified.
Changing the threshold in WHERE rank <= 3 extends the same query to the top three.
Deduplication with Row Number
ROW_NUMBER is also used as a deduplication tool, not just a ranking one. Taking only the
first of the numbered rows within a partition is the “one representative per group”
pattern:
sqlite3 -box -header <<'SQL' CREATE TABLE member(id INTEGER PRIMARY KEY, name TEXT, branch TEXT); CREATE TABLE loan(id INTEGER PRIMARY KEY, book_id INT, member_id INT, pickup TEXT, returned TEXT); INSERT INTO member VALUES (1,'Alice','Kadikoy'),(2,'Ben','Kadikoy'),(3,'Clara','Uskudar'), (4,'Derek','Uskudar'),(5,'Evan','Besiktas'),(6,'Fiona','Besiktas'), (7,'Grace','Kadikoy'),(8,'Hannah','Uskudar'); INSERT INTO loan VALUES (1,1,1,'2024-03-01','2024-03-15'),(2,3,1,'2024-03-01','2024-03-20'), (3,5,1,'2024-03-04','2024-03-18'),(4,7,1,'2024-03-11',NULL), (5,9,1,'2024-03-18','2024-03-29'),(6,2,2,'2024-03-01','2024-03-12'), (7,4,2,'2024-03-06','2024-03-25'),(8,6,2,'2024-03-11','2024-03-19'), (9,8,2,'2024-03-21',NULL),(10,1,3,'2024-03-04','2024-03-10'), (11,3,3,'2024-03-06','2024-03-27'),(12,10,3,'2024-03-13','2024-03-22'), (13,2,3,'2024-03-25',NULL),(14,5,4,'2024-03-04','2024-03-09'), (15,7,4,'2024-03-13','2024-03-26'),(16,4,4,'2024-03-20',NULL), (17,6,5,'2024-03-06','2024-03-14'),(18,9,5,'2024-03-11','2024-03-23'), (19,1,5,'2024-03-25','2024-03-28'),(20,8,6,'2024-03-04','2024-03-17'), (21,10,6,'2024-03-13','2024-03-21'),(22,3,6,'2024-03-20',NULL), (23,2,7,'2024-03-11','2024-03-16'),(24,5,7,'2024-03-18','2024-03-24'), (25,4,8,'2024-03-13','2024-03-24'); WITH numbered AS ( SELECT id, member_id, pickup, ROW_NUMBER() OVER (PARTITION BY member_id ORDER BY pickup DESC, id DESC) AS n FROM loan ) SELECT n.member_id, m.name, n.id AS loan_id, n.pickup FROM numbered n JOIN member m ON m.id = n.member_id WHERE n.n = 1 ORDER BY n.member_id; SQL
┌───────────┬────────┬─────────┬────────────┐ │ member_id │ name │ loan_id │ pickup │ ├───────────┼────────┼─────────┼────────────┤ │ 1 │ Alice │ 5 │ 2024-03-18 │ │ 2 │ Ben │ 9 │ 2024-03-21 │ │ 3 │ Clara │ 13 │ 2024-03-25 │ │ 4 │ Derek │ 16 │ 2024-03-20 │ │ 5 │ Evan │ 19 │ 2024-03-25 │ │ 6 │ Fiona │ 22 │ 2024-03-20 │ │ 7 │ Grace │ 24 │ 2024-03-18 │ │ 8 │ Hannah │ 25 │ 2024-03-13 │ └───────────┴────────┴─────────┴────────────┘
The sort key pickup DESC, id DESC has two columns. pickup alone can repeat within the
same member; adding id, which is a primary key, makes the result deterministic. This is
the previous section’s rule put into practice.
The same result could have been produced with the MAX subquery from the Correlated
Subqueries lesson. The difference between the two phrasings shows up on a tie: the
subquery-based phrasing returns both records that share the same date, the row-number
phrasing selects exactly one.
Summary
ROW_NUMBERdoes not recognize ties,RANKgives ties the same rank and skips, andDENSE_RANKgives ties the same rank without skipping.RANKanswers “how many rows are ahead of me,”DENSE_RANKanswers “how many distinct values are ahead of me”;ROW_NUMBERproduces not a rank but an identity.ROW_NUMBERandNTILEresults are not deterministic if the sort key does not determine the rows uniquely; a unique column has to be added to the key.- Filtering on a window result requires wrapping the query in a common table expression;
in a top-N-per-partition selection,
RANKreturns all the tied rows andROW_NUMBERreturns exactly N. PERCENT_RANKgives the first row 0,CUME_DISTgives the last row 1;NTILEcan split peer rows into different tiles.
Next Step
Every query written up to this point produced a result whose columns were known at the time the query was written. In reports, though, the opposite is often wanted: values sitting in rows should move up to a column header, so that each genre or each month becomes a separate column. This transformation reduces the row count and raises the column count, and it runs directly against SQL’s rule of a fixed column list. The next lesson shows how pivot operations are written with conditional aggregation and how the reverse transformation, from column to row, is done.
To keep your progress and take notes, Log in
My notes
Log in to take notes.