← Back to TableNotFound
SQL Window Functions ~1,400 words (draft)

ROW_NUMBER vs RANK vs DENSE_RANK

Window functions explained, with a real dataset and verified output · DRAFT — outline + worked examples, prose not yet written

1. Why this trio trips people up

What this section should cover
✍️ Draft note — replace this box with your own prose, then delete it
  • What's the actual moment — an interview question, a production bug, a dashboard that looked wrong — where you first had to pick between these three?
  • In one sentence, before any syntax: what's a window function, for someone who's never heard the term?
  • What's the most common wrong assumption you've seen — that these are aggregate functions, or that ORDER BY inside OVER() controls the final row order?

2. The mental model: PARTITION BY and ORDER BY

Quick primer on OVER() before touching the three functions individually — keep this short
✍️ Draft note — replace this box with your own prose, then delete it
  • What's the simplest analogy you use to explain PARTITION BY to someone who already understands GROUP BY?
  • Is there a specific moment or phrasing that made window functions "click" for you the first time?

3. The dataset

Introduce the table and why the ties are deliberate — Engineering has a 2-way tie, Sales a 3-way tie, Marketing two separate 2-way ties

Verified by actually running this against SQLite — not hand-computed.

schema.sql
CREATE TABLE employees (
  emp_id INTEGER PRIMARY KEY,
  name TEXT NOT NULL,
  department TEXT NOT NULL,
  salary INTEGER NOT NULL
);

INSERT INTO employees (emp_id, name, department, salary) VALUES
  (1,  'Alice Chen',      'Engineering', 95000),
  (2,  'Bob Martinez',    'Engineering', 90000),
  (3,  'Carol Nguyen',    'Engineering', 90000),
  (4,  'Dave Okafor',     'Engineering', 88000),
  (5,  'Eve Patel',       'Engineering', 85000),
  (6,  'Frank Lee',       'Sales',       82000),
  (7,  'Grace Kim',       'Sales',       82000),
  (8,  'Heidi Wagner',    'Sales',       82000),
  (9,  'Ivan Petrov',     'Sales',       79000),
  (10, 'Judy Alvarez',    'Marketing',   91000),
  (11, 'Mallory Singh',   'Marketing',   91000),
  (12, 'Niaj Rahman',     'Marketing',   87000),
  (13, 'Olivia Brooks',   'Marketing',   84000),
  (14, 'Peggy Torres',    'Marketing',   84000),
  (15, 'Sybil Costa',     'Marketing',   80000);
✍️ Draft note — replace this box with your own prose, then delete it
  • Is this the kind of dataset shape you actually see ties in at work — salary bands, event timestamps, test scores? Give a real example.
  • Why department + salary instead of, say, timestamps or scores — any reason to keep or swap it?

4. ROW_NUMBER() — always unique, no exceptions

Definition, the query, the verified output, and the one thing to remember: ties get an arbitrary but unique number
row_number.sql
SELECT department, name, salary,
       ROW_NUMBER() OVER (
         PARTITION BY department
         ORDER BY salary DESC
       ) AS row_num
FROM employees
ORDER BY department, salary DESC;
departmentnamesalaryrow_num
EngineeringAlice Chen950001
EngineeringBob Martinez900002
EngineeringCarol Nguyen900003
EngineeringDave Okafor880004
EngineeringEve Patel850005
MarketingJudy Alvarez910001
MarketingMallory Singh910002
MarketingNiaj Rahman870003
MarketingOlivia Brooks840004
MarketingPeggy Torres840005
MarketingSybil Costa800006
SalesFrank Lee820001
SalesGrace Kim820002
SalesHeidi Wagner820003
SalesIvan Petrov790004

Notice Bob and Carol — tied at 90000 — still get different numbers (2 and 3). ROW_NUMBER() doesn't know or care about the tie.

✍️ Draft note — replace this box with your own prose, then delete it
  • What's a real bug you've seen (or caused) from assuming ROW_NUMBER()'s tie-breaking order was stable when it wasn't?
  • What's your go-to real use case for ROW_NUMBER — deduplication, pagination, "latest record per key"?

5. RANK() — ties share a rank, then it skips

Definition, the query, the verified output, and the gap behavior right after a tie
rank.sql
SELECT department, name, salary,
       RANK() OVER (
         PARTITION BY department
         ORDER BY salary DESC
       ) AS rnk
FROM employees
ORDER BY department, salary DESC;
departmentnamesalaryrnk
EngineeringAlice Chen950001
EngineeringBob Martinez900002
EngineeringCarol Nguyen900002
EngineeringDave Okafor880004
EngineeringEve Patel850005
MarketingJudy Alvarez910001
MarketingMallory Singh910001
MarketingNiaj Rahman870003
MarketingOlivia Brooks840004
MarketingPeggy Torres840004
MarketingSybil Costa800006
SalesFrank Lee820001
SalesGrace Kim820001
SalesHeidi Wagner820001
SalesIvan Petrov790004

In Sales, three people tie for rank 1 — and the next rank is 4, not 2. RANK() leaves a gap the size of the tie group.

✍️ Draft note — replace this box with your own prose, then delete it
  • Where has that gap actually mattered in a real report or dashboard — did a stakeholder ever ask "why did we skip from rank 1 to rank 4"?
  • Any story of RANK() being used where DENSE_RANK() was what the business actually wanted, or the reverse?

6. DENSE_RANK() — ties share a rank, nothing skips

Definition, the query, the verified output, and how it differs from RANK by never skipping
dense_rank.sql
SELECT department, name, salary,
       DENSE_RANK() OVER (
         PARTITION BY department
         ORDER BY salary DESC
       ) AS dense_rnk
FROM employees
ORDER BY department, salary DESC;
departmentnamesalarydense_rnk
EngineeringAlice Chen950001
EngineeringBob Martinez900002
EngineeringCarol Nguyen900002
EngineeringDave Okafor880003
EngineeringEve Patel850004
MarketingJudy Alvarez910001
MarketingMallory Singh910001
MarketingNiaj Rahman870002
MarketingOlivia Brooks840003
MarketingPeggy Torres840003
MarketingSybil Costa800004
SalesFrank Lee820001
SalesGrace Kim820001
SalesHeidi Wagner820001
SalesIvan Petrov790002

Same three-way tie in Sales — but the next rank is 2, not 4. No gaps, ever.

✍️ Draft note — replace this box with your own prose, then delete it
  • When have you specifically reached for DENSE_RANK — tiered pricing, medal/leaderboard-style ranking, "top N distinct values" queries?
  • Has DENSE_RANK() ever silently returned more rows than a "top N" query intended, because of a tie you didn't expect?

7. All three, side by side

The combined query — this is the table that makes the difference click
combined.sql
SELECT department, name, salary,
       ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS row_num,
       RANK()       OVER (PARTITION BY department ORDER BY salary DESC) AS rnk,
       DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dense_rnk
FROM employees
ORDER BY department, salary DESC;
departmentnamesalaryrow_numrnkdense_rnk
EngineeringAlice Chen95000111
EngineeringBob Martinez90000222
EngineeringCarol Nguyen90000322
EngineeringDave Okafor88000443
EngineeringEve Patel85000554
MarketingJudy Alvarez91000111
MarketingMallory Singh91000211
MarketingNiaj Rahman87000332
MarketingOlivia Brooks84000443
MarketingPeggy Torres84000543
MarketingSybil Costa80000664
SalesFrank Lee82000111
SalesGrace Kim82000211
SalesHeidi Wagner82000311
SalesIvan Petrov79000442

Every row where the three columns diverge is a tie. Watch Marketing: two separate tie groups (91000 and 84000) — DENSE_RANK compresses both gaps, RANK leaves both.

✍️ Draft note — replace this box with your own prose, then delete it
  • What's the fastest way you'd explain this table to a junior engineer in 30 seconds, pointing at the screen?
  • Do you have a personal mnemonic for which one skips and which one doesn't?

8. Which one should you actually use?

Practical decision guide — dedup vs. human-facing ranking vs. tiering
✍️ Draft note — replace this box with your own prose, then delete it
  • Give three real query patterns you've written recently, one per function — what was each one for?
  • What's your rule of thumb — e.g. "deduping/pagination → ROW_NUMBER, competition-style ranking → RANK, tiering/grouping → DENSE_RANK"? Do you actually follow that, or is it messier in practice?

9. What actually breaks in production

The most valuable section — specific incidents, not generic advice
✍️ Draft note — replace this box with your own prose, then delete it
  • Describe a specific production incident or wrong number in a dashboard caused by picking the wrong one of these three.
  • What's the most common code review comment you leave when you see one of these misused?
  • Have you seen ROW_NUMBER() return a different "row 1" on different runs because ORDER BY wasn't fully deterministic (no tiebreaker column)? What happened downstream?
  • Any story about performance — one of these over a huge partition with no supporting index, and what it did to the query plan?

10. Cheat sheet

One compact table for people skimming — keep the prose minimal here
FunctionTiesAfter a tieTypical use
ROW_NUMBER()Always uniqueNever skips (nothing to skip)Dedup, pagination, "latest per key"
RANK()Same rankSkips by tie-group sizeCompetition-style ranking
DENSE_RANK()Same rankNever skipsTiering, "top N distinct values"
✍️ Draft note — replace this box with your own prose, then delete it
  • Is there a phrasing or mnemonic you'd want future-you to see when skimming this in six months?

11. Try it yourself

Close the loop — point to hands-on practice, one clear takeaway
✍️ Draft note — replace this box with your own prose, then delete it
  • What's the single thing you want a reader to remember above everything else in this article?

Practice window functions live on TableNotFound →