SQL (PostgreSQL, MySQL, SQLite) Module 8 – Window functions
ROW_NUMBER, RANK, DENSE_RANK and NTILE
How ROW_NUMBER, RANK and DENSE_RANK number tied rows, ranking within groups with PARTITION BY, and dealing rows into equal buckets with NTILE.
What you will learn
- Rank rows within partitions with ROW_NUMBER, RANK and DENSE_RANK
- Choose the ranking function that treats ties the way a question needs
- Remove duplicate rows with row numbers that a unique tie-breaker makes repeatable
- Split rows into buckets with NTILE and place them with PERCENT_RANK and CUME_DIST
Before you start
What you’ll learn · Before you start
What you will learn
- Rank rows within partitions with ROW_NUMBER, RANK and DENSE_RANK
- Choose the ranking function that treats ties the way a question needs
- Remove duplicate rows with row numbers that a unique tie-breaker makes repeatable
- Split rows into buckets with NTILE and place them with PERCENT_RANK and CUME_DIST
Before you start
On this page
ROW_NUMBER, RANK, DENSE_RANK and NTILE are window functions that number the rows of each window in the order of its
ORDER BY. They differ in what they do with ties, rows that are equal in that order. ROW_NUMBER gives every row a
number of its own, 1, 2, 3 and so on, and puts tied rows in whatever order they arrive. RANK gives tied rows the same
number and then skips, as in a race where two runners share first place and the next one is third. DENSE_RANK gives
tied rows the same number without skipping. NTILE(n) does not rank at all: it deals the rows into n buckets of nearly
equal size. So the choice depends on the question: one number per row, places with gaps after a tie, or a count of
the distinct values ahead.
All four work the same way in PostgreSQL, MySQL and SQLite, and each example below runs on SQLite 3.49.1 inside this page.
Four numbers for the same eight offers
The examples rank the job offers made to the final-year students of one college, by branch.
India-specific
The packages are in lakh rupees per annum (LPA), the way Indian colleges report campus placement offers: 12.0 means ₹12,00,000 a year.
Here are the eight offers of the CSE branch, numbered four ways from the highest package down:
-- Placement offers of one college's final-year students, in three branches.
-- lpa: the package offered, in lakh rupees a year.
CREATE TABLE offers (
student_id INTEGER PRIMARY KEY,
student TEXT NOT NULL,
branch TEXT NOT NULL,
lpa REAL NOT NULL
);
INSERT INTO offers VALUES
(1, 'Aarav', 'CSE', 12.0),
(2, 'Diya', 'CSE', 18.5),
(3, 'Ishaan', 'CSE', 12.0),
(4, 'Kavya', 'CSE', 9.5),
(5, 'Nikhil', 'CSE', 12.0),
(6, 'Riya', 'CSE', 18.5),
(7, 'Sana', 'CSE', 7.0),
(8, 'Tanvi', 'CSE', 9.5),
(9, 'Arjun', 'ECE', 10.0),
(10, 'Meher', 'ECE', 14.0),
(11, 'Pooja', 'ECE', 10.0),
(12, 'Rahul', 'ECE', 8.5),
(13, 'Farah', 'ME', 7.5),
(14, 'Gopal', 'ME', 9.0),
(15, 'Harsh', 'ME', 6.0);
-- The eight CSE offers numbered four ways, from the highest package down.
SELECT student, lpa,
row_number() OVER (ORDER BY lpa DESC) AS rn,
rank() OVER (ORDER BY lpa DESC) AS rnk,
dense_rank() OVER (ORDER BY lpa DESC) AS dense,
ntile(3) OVER (ORDER BY lpa DESC) AS third
FROM offers
WHERE branch = 'CSE'
ORDER BY rn; Output
┌─────────┬──────┬────┬─────┬───────┬───────┐ │ student │ lpa │ rn │ rnk │ dense │ third │ ├─────────┼──────┼────┼─────┼───────┼───────┤ │ Diya │ 18.5 │ 1 │ 1 │ 1 │ 1 │ │ Riya │ 18.5 │ 2 │ 1 │ 1 │ 1 │ │ Aarav │ 12.0 │ 3 │ 3 │ 2 │ 1 │ │ Ishaan │ 12.0 │ 4 │ 3 │ 2 │ 2 │ │ Nikhil │ 12.0 │ 5 │ 3 │ 2 │ 2 │ │ Kavya │ 9.5 │ 6 │ 6 │ 3 │ 2 │ │ Tanvi │ 9.5 │ 7 │ 6 │ 3 │ 3 │ │ Sana │ 7.0 │ 8 │ 8 │ 4 │ 3 │ └─────────┴──────┴────┴─────┴───────┴───────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < four_rankings.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
Look at the three offers of 12.0, made to Aarav, Ishaan and Nikhil:
- ROW_NUMBER numbered them 3, 4 and 5. Every row gets a different number, so the numbers count rows.
- RANK gave all three the number 3, and the next package, 9.5, is ranked 6. Three students share third place, so 4 and 5 are skipped.
- DENSE_RANK gave them 2, and the next package gets 3. Its numbers count the different packages, without gaps.
- NTILE(3) dealt the eight rows into three buckets of 3, 3 and 2 rows. It ignores ties, so Aarav landed in the first bucket and Ishaan and Nikhil in the second, although all three have the same package.
RANK and DENSE_RANK number groups of peers, not single rows
Text description of the diagram
The diagram shows the eight CSE offers of the lesson, ordered from the highest package down, as four groups of peers: rows with the same package.
- The first group is the package 18.5, with Diya and Riya, in positions 1 and 2. No row comes before it, so RANK is 1 and DENSE_RANK is 1.
- The second group is the package 12.0, with Aarav, Ishaan and Nikhil, in positions 3 to 5. Two rows come before it, so RANK is 3; one group comes before it, so DENSE_RANK is 2.
- The third group is the package 9.5, with Kavya and Tanvi, in positions 6 and 7. Five rows come before it, so RANK is 6; two groups come before it, so DENSE_RANK is 3.
- The fourth group is the package 7.0, with Sana alone, in position 8. Seven rows come before it, so RANK is 8; three groups come before it, so DENSE_RANK is 4.
The rules at the bottom: ROW_NUMBER is the row's position, 1 to 8, different for every row. RANK is 1 plus the number of rows before the row's group. DENSE_RANK is 1 plus the number of groups before it.
Rows that are equal in the window’s ORDER BY are called peers. RANK is 1 plus the number of rows before the
current row’s group of peers, and DENSE_RANK is 1 plus the number of peer groups before it. ROW_NUMBER has no rule
for peers: Diya came before Riya here only because her row was read first. Unless a column breaks the tie, as in
ORDER BY lpa DESC, student_id, the numbers of tied rows may change from one run of the same query to the next.
| Criterion | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| The 12.0 offers | 3, 4, 5 | 3, 3, 3 | 2, 2, 2 |
| Next: 9.5 | 6 | 6 | 3 |
| Highest number | 8: the rows | 8 here | 4: the values |
| Ties share a number | No | Yes | Yes |
| When to choose | One row per number | Places with gaps | Levels without gaps |
NTILE answers another question, so it is not in the table: it gives the bucket, 1 to n, and two tied rows can land in different buckets. Pick ROW_NUMBER when each number must belong to exactly one row, such as keeping one row per key; RANK for places that show how many rows are ahead; DENSE_RANK for levels, such as the second-highest package; and NTILE for groups of equal size, such as quartiles or deciles.
Ranking inside each branch
With PARTITION BY, each branch is numbered on its own, and the numbers start again at 1:
-- Placement offers of one college's final-year students, in three branches.
-- lpa: the package offered, in lakh rupees a year.
CREATE TABLE offers (
student_id INTEGER PRIMARY KEY,
student TEXT NOT NULL,
branch TEXT NOT NULL,
lpa REAL NOT NULL
);
INSERT INTO offers VALUES
(1, 'Aarav', 'CSE', 12.0),
(2, 'Diya', 'CSE', 18.5),
(3, 'Ishaan', 'CSE', 12.0),
(4, 'Kavya', 'CSE', 9.5),
(5, 'Nikhil', 'CSE', 12.0),
(6, 'Riya', 'CSE', 18.5),
(7, 'Sana', 'CSE', 7.0),
(8, 'Tanvi', 'CSE', 9.5),
(9, 'Arjun', 'ECE', 10.0),
(10, 'Meher', 'ECE', 14.0),
(11, 'Pooja', 'ECE', 10.0),
(12, 'Rahul', 'ECE', 8.5),
(13, 'Farah', 'ME', 7.5),
(14, 'Gopal', 'ME', 9.0),
(15, 'Harsh', 'ME', 6.0);
-- PARTITION BY branch: every branch is ranked on its own.
SELECT branch, student, lpa,
rank() OVER (PARTITION BY branch ORDER BY lpa DESC) AS rnk,
dense_rank() OVER (PARTITION BY branch ORDER BY lpa DESC) AS dense,
count(*) OVER (PARTITION BY branch) AS students
FROM offers
ORDER BY branch, dense, student; Output
┌────────┬─────────┬──────┬─────┬───────┬──────────┐ │ branch │ student │ lpa │ rnk │ dense │ students │ ├────────┼─────────┼──────┼─────┼───────┼──────────┤ │ CSE │ Diya │ 18.5 │ 1 │ 1 │ 8 │ │ CSE │ Riya │ 18.5 │ 1 │ 1 │ 8 │ │ CSE │ Aarav │ 12.0 │ 3 │ 2 │ 8 │ │ CSE │ Ishaan │ 12.0 │ 3 │ 2 │ 8 │ │ CSE │ Nikhil │ 12.0 │ 3 │ 2 │ 8 │ │ CSE │ Kavya │ 9.5 │ 6 │ 3 │ 8 │ │ CSE │ Tanvi │ 9.5 │ 6 │ 3 │ 8 │ │ CSE │ Sana │ 7.0 │ 8 │ 4 │ 8 │ │ ECE │ Meher │ 14.0 │ 1 │ 1 │ 4 │ │ ECE │ Arjun │ 10.0 │ 2 │ 2 │ 4 │ │ ECE │ Pooja │ 10.0 │ 2 │ 2 │ 4 │ │ ECE │ Rahul │ 8.5 │ 4 │ 3 │ 4 │ │ ME │ Gopal │ 9.0 │ 1 │ 1 │ 3 │ │ ME │ Farah │ 7.5 │ 2 │ 2 │ 3 │ │ ME │ Harsh │ 6.0 │ 3 │ 3 │ 3 │ └────────┴─────────┴──────┴─────┴───────┴──────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < rank_in_branch.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
In ECE, Arjun’s and Pooja’s offers of 10.0 share second place under both functions. Rahul’s 8.5 then comes fourth by RANK, because three students are ahead of him, and third by DENSE_RANK, because two different packages are. In ME there are no ties, so the two functions agree. The lesson on top-N per group in this module builds on such ranked lists.
NTILE, PERCENT_RANK and CUME_DIST
Three more functions place a row within its window instead of numbering it:
- NTILE(n) deals the rows, in order, into n buckets whose sizes differ by at most one row.
- PERCENT_RANK is a row’s RANK as a fraction, from 0 for the first group of peers up to at most 1.
- CUME_DIST is the share of the window’s rows that come before the current row or tie with it.
NTILE(4) gives quartiles and NTILE(10) deciles. PERCENT_RANK and CUME_DIST give every peer the same value, while NTILE can put two peers in different buckets, and that difference changes who counts as the top 10 % of a list.
Descriptive Statistics Calculator Paste a list of numbers to see its quartiles and percentiles, and compare them with what NTILE and CUME_DIST return in SQL.Your turn
Exercise · Easy · SQL
Places in each branch by CGPA, with ties sharing a place
The table students has one row per final-year student:
student_id: the student;name: the student's name;branch:'CSE','ECE','ME'and so on;cgpa: the cumulative grade point average, from 0 to 10, never NULL.
Return every student with four columns: branch, name, cgpa and place. place is the student's place within their own branch, from the highest CGPA down. Students with the same CGPA share a place, and the next CGPA gets the next place, with no gap: CGPAs of 9.1, 8.4, 8.4 and 7.9 get the places 1, 2, 2 and 3.
The order of the rows is not checked.
Starter code · places.sql
-- Each student's place within their branch, from the highest CGPA down.
-- So far every branch is ranked together, and a tie leaves a gap.
SELECT branch, name, cgpa,
rank() OVER (ORDER BY cgpa DESC) AS place
FROM students; The sample tests · tests.yaml
# Sample tests: each one builds a small students table, runs your query on it and compares the rows it returns (in any
# order).
tests:
- name: two branches, with a tie in CSE
setup: |
CREATE TABLE students (student_id INTEGER PRIMARY KEY, name TEXT NOT NULL, branch TEXT NOT NULL, cgpa REAL NOT NULL);
INSERT INTO students VALUES
(1, 'Anaya', 'CSE', 8.4),
(2, 'Bhavin', 'CSE', 9.1),
(3, 'Chetan', 'CSE', 7.9),
(4, 'Diksha', 'CSE', 8.4),
(5, 'Eshan', 'ECE', 7.9),
(6, 'Fiza', 'ECE', 8.6);
expected:
columns: [branch, name, cgpa, place]
rows:
- ['CSE', 'Bhavin', 9.1, 1]
- ['CSE', 'Anaya', 8.4, 2]
- ['CSE', 'Diksha', 8.4, 2]
- ['CSE', 'Chetan', 7.9, 3]
- ['ECE', 'Fiza', 8.6, 1]
- ['ECE', 'Eshan', 7.9, 2]
- name: a branch where everyone has the same CGPA, and a branch of one student
setup: |
CREATE TABLE students (student_id INTEGER PRIMARY KEY, name TEXT NOT NULL, branch TEXT NOT NULL, cgpa REAL NOT NULL);
INSERT INTO students VALUES
(1, 'Gauri', 'ME', 8.0),
(2, 'Hemant', 'ME', 8.0),
(3, 'Ilina', 'ME', 8.0),
(4, 'Jatin', 'CIVIL', 6.5);
expected:
columns: [branch, name, cgpa, place]
rows:
- ['ME', 'Gauri', 8.0, 1]
- ['ME', 'Hemant', 8.0, 1]
- ['ME', 'Ilina', 8.0, 1]
- ['CIVIL', 'Jatin', 6.5, 1]
- name: two ties in a row, so that a gap would show
setup: |
CREATE TABLE students (student_id INTEGER PRIMARY KEY, name TEXT NOT NULL, branch TEXT NOT NULL, cgpa REAL NOT NULL);
INSERT INTO students VALUES
(1, 'Kabir', 'CSE', 9.5),
(2, 'Lavanya', 'CSE', 9.5),
(3, 'Madhav', 'CSE', 9.0),
(4, 'Neha', 'CSE', 9.0),
(5, 'Om', 'CSE', 8.2);
expected:
columns: [branch, name, cgpa, place]
rows:
- ['CSE', 'Kabir', 9.5, 1]
- ['CSE', 'Lavanya', 9.5, 1]
- ['CSE', 'Madhav', 9.0, 2]
- ['CSE', 'Neha', 9.0, 2]
- ['CSE', 'Om', 8.2, 3] A hint
Each branch is ranked on its own, so the window needs PARTITION BY branch and an ORDER BY from the highest CGPA down. Of the ranking functions, one leaves a gap after a tie (1, 2, 2, 4) and one does not (1, 2, 2, 3): the places here have no gaps.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Ties and tie-breakers: numbering that stays put, reproduced Pro: opens with a Pro, Premium or Ultimate pass
A ROW_NUMBER over tied values is only as stable as the plan that produced it. This part renumbers tied rows without changing any data, fixes the numbering with a tie-breaker, and uses the fixed form to delete duplicate rows.
In this part:
- An index that renumbers tied students
- A unique tie-breaker that fixes the numbers
- Deleting duplicates while keeping the latest row per key
- Which function answers “the top three”
4 runnable examples (SQL) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Buckets and percentiles with NTILE, PERCENT_RANK and CUME_DIST, with numbers Pro: opens with a Pro, Premium or Ultimate pass
NTILE, PERCENT_RANK and CUME_DIST all say where a row sits in its window, and they disagree whenever rows tie. This part works out the bucket sizes and both fractions by hand, then gets three different answers to “the top 10 % of customers” from the same twenty rows.
In this part:
- How NTILE shares out rows that do not divide evenly
- PERCENT_RANK and CUME_DIST, worked by hand
- The top 10 % of customers, three ways
- The same functions in PostgreSQL, MySQL and SQLite
3 runnable examples (SQL) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
For the values 30, 20, 20 and 10, ranked from the highest down, what do ROW_NUMBER, RANK and DENSE_RANK return, and when would you use each?
ROW_NUMBER gives 1, 2, 3 and 4: every row a number of its own, with the two 20s in no defined order unless a tie-breaker column is added to the ORDER BY. RANK gives 1, 2, 2 and 4: tied rows share a place and the next place is skipped, as in a race. DENSE_RANK gives 1, 2, 2 and 3: tied rows share a place and no number is skipped, so the numbers count the distinct values. Use ROW_NUMBER to pick exactly one row per number, for example one row per key when removing duplicates; RANK for places that must show how many rows are ahead; and DENSE_RANK for questions about distinct levels, such as the second-highest value.
-
Fresher to mid
Return the second-highest package of each branch. In CSE two students share the highest package, 18.5, and the next package is 12.0. What does your query return for CSE, and what should it return?
-
Mid to senior
A nightly job marks each customer's first order with ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date) = 1. After a database upgrade, a few hundred customers have a different first order, although no order changed. What happened, and how do you make it impossible?
-
Mid to senior
Marketing wants the top 10 % of 1,000 customers by spend for a campaign, and spend is often tied at round amounts. Compare NTILE(10), PERCENT_RANK, CUME_DIST and a ROW_NUMBER cut, and choose one.
Key takeaways
- ROW_NUMBER numbers every row, RANK gives ties the same place and skips after them, DENSE_RANK gives ties the same place without gaps, and NTILE(n) deals the rows into n buckets.
PARTITION BYranks each group on its own; the numbers start again at 1 in every partition.- ROW_NUMBER and NTILE split ties in whatever order the rows arrive, so give them a unique tie-breaker such as the primary key.
- PERCENT_RANK and CUME_DIST place a row as a fraction of its window and treat peers alike.
References
- PostgreSQL documentation: Window Functions (The PostgreSQL Global Development Group)
- PostgreSQL documentation: Value Expressions (window function calls) (The PostgreSQL Global Development Group)
- MySQL 8.4 Reference Manual: Window Function Descriptions (Oracle Corporation)
- MySQL 8.4 Reference Manual: Named Windows (Oracle Corporation)
- MySQL 8.4 Reference Manual: Keywords and Reserved Words (Oracle Corporation)
- MySQL 8.4 Reference Manual: DELETE Statement (Oracle Corporation)
- SQLite: Window Functions (SQLite)
- SQLite: EXPLAIN QUERY PLAN (SQLite)
- SQLite: The INDEXED BY Clause (SQLite)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress