SQLRANKDENSE_RANKwindow

Что такое RANK и DENSE_RANK в SQL?

RANK и DENSE_RANK — это ранжирование, где одинаковые значения получают одинаковый ранг. Простыми словами: разница между ROW_NUMBER (всегда уникально), RANK (одинаковые значения → одинаковый ранг с пропусками после) и DENSE_RANK (одинаковый ранг без пропусков). С таблицами, олимпийским сравнением и частыми ошибками.

11 мин чтенияСправочникSQL · RANK · DENSE_RANK · window · tutorial

RANK и DENSE_RANK — это оконные функции для ранжирования строк.

Они нужны, когда важно не просто пронумеровать строки подряд, а выдать им места в рейтинге: первое место, второе место, третье место и так дальше.

Главная особенность RANK и DENSE_RANK: они умеют правильно работать с ничьими. Если у двух строк одинаковое значение для сортировки, они получают одинаковый ранг.

Например, два игрока набрали по 90 очков. Это не «второе и третье место». Это два человека на одном месте.

Вот здесь обычный ROW_NUMBER уже не подходит, потому что он всегда выдаёт уникальные номера: 1, 2, 3, 4, 5. Даже если значения одинаковые.

А RANK и DENSE_RANK смотрят на смысл рейтинга: если результат одинаковый, место тоже одинаковое.

Главное отличие RANK, DENSE_RANK и ROW_NUMBER

Посмотрим на три функции рядом:

  • ROW_NUMBER — просто нумерует строки подряд;
  • RANK — даёт одинаковый ранг одинаковым значениям и оставляет пропуски после ничьей;
  • DENSE_RANK — даёт одинаковый ранг одинаковым значениям, но без пропусков.

Самая короткая шпаргалка:

Функция Результат при ничьей Пример
ROW_NUMBER одинаковых мест нет 1, 2, 3, 4
RANK одинаковое место и пропуск дальше 1, 2, 2, 4
DENSE_RANK одинаковое место без пропуска 1, 2, 2, 3

Разница кажется маленькой, но в отчётах она очень важна.

Олимпийская аналогия

Представь соревнование.

Участники набрали очки:

name score
Anna 95
Bob 90
Vera 90
Gregory 85
Denis 80

Anna на первом месте. Bob и Vera набрали одинаково, значит оба делят второе место.

Что будет дальше?

Если использовать олимпийскую логику, Gregory будет не на третьем месте, а на четвёртом. Потому что второе место заняли два человека: условно позиции 2 и 3 заняты, следующий получает 4.

Это логика RANK:

name score rank
Anna 95 1
Bob 90 2
Vera 90 2
Gregory 85 4
Denis 80 5

А DENSE_RANK думает иначе: «Мне важны не занятые позиции, а уровни результата».

Уровни такие:

  1. 95 очков;
  2. 90 очков;
  3. 85 очков;
  4. 80 очков.

Поэтому результат будет:

name score dense_rank
Anna 95 1
Bob 90 2
Vera 90 2
Gregory 85 3
Denis 80 4

RANK — это места в соревновании.
DENSE_RANK — это уровни значений.

Синтаксис RANK и DENSE_RANK

Обе функции пишутся как оконные функции, через OVER.

RANK() OVER (ORDER BY score DESC)
DENSE_RANK() OVER (ORDER BY score DESC)

Внутри OVER обязательно нужен ORDER BY, потому что ранг без сортировки не имеет смысла.

Нужно явно сказать базе, по какому признаку определять первое, второе и третье место.

Например:

SELECT
  name,
  score,
  RANK() OVER (ORDER BY score DESC) AS place
FROM players;

Так мы говорим:

Отсортируй игроков по очкам от большего к меньшему и выдай каждому место.

Если сортировка по возрастанию, меньшее значение будет считаться лучшим:

SELECT
  name,
  race_time,
  RANK() OVER (ORDER BY race_time ASC) AS place
FROM race_results;

Такой вариант подходит для забега, где меньшее время лучше.

Пример: сравним три функции

Создадим маленький набор данных прямо в запросе:

WITH scores AS (
  SELECT 'Anna' AS name, 95 AS score UNION ALL
  SELECT 'Bob', 90 UNION ALL
  SELECT 'Vera', 90 UNION ALL
  SELECT 'Gregory', 85 UNION ALL
  SELECT 'Denis', 80
)
SELECT
  name,
  score,
  ROW_NUMBER() OVER (ORDER BY score DESC) AS row_num,
  RANK() OVER (ORDER BY score DESC) AS rank_num,
  DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rank_num
FROM scores;

Результат:

name score row_num rank_num dense_rank_num
Anna 95 1 1 1
Bob 90 2 2 2
Vera 90 3 2 2
Gregory 85 4 4 3
Denis 80 5 5 4

Теперь разберём спокойно.

ROW_NUMBER выдал Bob номер 2, а Vera номер 3. Хотя у них одинаковые очки. Для простой нумерации это нормально, но для рейтинга — странно.

RANK выдал Bob и Vera одинаковое место 2. После этого Gregory получил 4, потому что третье место как бы занято второй строкой с тем же результатом.

DENSE_RANK тоже выдал Bob и Vera одинаковое место 2. Но Gregory получил 3, потому что DENSE_RANK не делает пропусков.

Когда использовать RANK

RANK хорошо подходит, когда ты хочешь показать именно место в рейтинге.

Например:

  • место спортсмена в соревновании;
  • место игрока в лидерборде;
  • место студента по баллам;
  • место товара по продажам;
  • место менеджера по выручке.

Типичная логика: если несколько участников разделили место, следующий участник получает место с учётом пропуска.

Пример:

SELECT
  name,
  total_points,
  RANK() OVER (ORDER BY total_points DESC) AS place
FROM athletes;

Если два спортсмена набрали одинаковое количество очков, они получат одинаковое place.

Это честнее, чем насильно разводить их по разным местам через ROW_NUMBER.

Когда использовать DENSE_RANK

DENSE_RANK лучше подходит, когда тебе нужны плотные уровни без пропусков.

Например:

  • ценовые уровни товаров;
  • группы клиентов по сумме покупок;
  • уровни зарплат;
  • уровни рейтинга;
  • топ уникальных значений.

Допустим, есть товары с ценами:

name price
Laptop 1500
Monitor 700
Chair 700
Keyboard 120
Mouse 80

Запрос:

SELECT
  name,
  price,
  DENSE_RANK() OVER (ORDER BY price DESC) AS price_tier
FROM products;

Результат:

name price price_tier
Laptop 1500 1
Monitor 700 2
Chair 700 2
Keyboard 120 3
Mouse 80 4

Monitor и Chair попали в один ценовой уровень, потому что цена одинаковая. Следующий уровень — 3, без пропуска.

Здесь DENSE_RANK читается естественно: первый ценовой уровень, второй ценовой уровень, третий ценовой уровень.

RANK против DENSE_RANK на топах

Разница особенно заметна, когда ты фильтруешь топ значений.

Допустим, нужно взять товары из трёх самых дорогих ценовых уровней.

Лучше использовать DENSE_RANK:

WITH ranked_products AS (
  SELECT
    name,
    price,
    DENSE_RANK() OVER (ORDER BY price DESC) AS price_tier
  FROM products
)
SELECT
  name,
  price,
  price_tier
FROM ranked_products
WHERE price_tier <= 3;

Так ты получишь все товары из трёх верхних уровней цен.

Если использовать RANK, поведение может отличаться. Например, если на втором месте много товаров с одинаковой ценой, следующий ранг может прыгнуть сразу на 7. Тогда условие rank_num <= 3 не даст «три уровня», а даст только те строки, чьи олимпийские места попали в диапазон.

Поэтому простое правило такое:

  • нужны места в соревновании — бери RANK;
  • нужны плотные уровни без пропусков — бери DENSE_RANK.

PARTITION BY: ранг внутри каждой группы

RANK и DENSE_RANK часто используют вместе с PARTITION BY.

PARTITION BY делит строки на группы, а ранжирование начинается заново внутри каждой группы.

Например, нужно найти место товара внутри своей категории:

SELECT
  category,
  name,
  price,
  RANK() OVER (
    PARTITION BY category
    ORDER BY price DESC
  ) AS rank_in_category
FROM products;

Что здесь происходит:

  • PARTITION BY category делит товары по категориям;
  • ORDER BY price DESC сортирует товары внутри каждой категории от дорогих к дешёвым;
  • RANK выдаёт место товара внутри категории.

То есть товар из категории Electronics не соревнуется с товаром из категории Furniture. У каждой категории свой маленький рейтинг.

Пример: топ-3 товара в каждой категории

Одна из самых частых задач:

Найти топ-3 товара по цене в каждой категории.

Для этого сначала считаем ранг в CTE, потом фильтруем:

WITH ranked_products AS (
  SELECT
    category,
    name,
    price,
    RANK() OVER (
      PARTITION BY category
      ORDER BY price DESC
    ) AS rank_in_category
  FROM products
)
SELECT
  category,
  name,
  price,
  rank_in_category
FROM ranked_products
WHERE rank_in_category <= 3;

Почему нужен CTE?

Потому что оконные функции нельзя использовать напрямую в WHERE. Сначала SQL фильтрует строки через WHERE, а уже потом считает оконные функции. В момент работы WHERE ранга ещё нет.

Поэтому мы делаем в два шага:

  1. Внутри ranked_products считаем ранг.
  2. Снаружи фильтруем уже готовый результат.

Почему нельзя использовать RANK прямо в WHERE

Новички часто пытаются написать так:

SELECT
  category,
  name,
  price,
  RANK() OVER (
    PARTITION BY category
    ORDER BY price DESC
  ) AS rank_in_category
FROM products
WHERE rank_in_category <= 3;

Такой запрос не сработает.

Причина: WHERE выполняется раньше, чем считается rank_in_category.

Правильный вариант:

WITH ranked_products AS (
  SELECT
    category,
    name,
    price,
    RANK() OVER (
      PARTITION BY category
      ORDER BY price DESC
    ) AS rank_in_category
  FROM products
)
SELECT
  category,
  name,
  price,
  rank_in_category
FROM ranked_products
WHERE rank_in_category <= 3;

Это общий приём для всех оконных функций: если нужно отфильтровать по результату окна, используй CTE или подзапрос.

Лидерборд игроков

Теперь возьмём задачу из продукта: лидерборд игроков.

SELECT
  name,
  score,
  RANK() OVER (ORDER BY score DESC) AS place
FROM players
ORDER BY place, name;

Так мы получим таблицу с местами.

Если два игрока набрали одинаковое количество очков, у них будет одинаковое место.

Но есть важный нюанс с LIMIT.

На первый взгляд можно написать:

SELECT
  name,
  score,
  RANK() OVER (ORDER BY score DESC) AS place
FROM players
ORDER BY place
LIMIT 10;

Запрос вернёт 10 строк. Но если на десятом месте ничья, часть игроков с тем же местом может не попасть в результат.

Для честного топа по местам лучше фильтровать по рангу:

WITH ranked_players AS (
  SELECT
    name,
    score,
    RANK() OVER (ORDER BY score DESC) AS place
  FROM players
)
SELECT
  name,
  score,
  place
FROM ranked_players
WHERE place <= 10
ORDER BY place, name;

Так в результат попадут все игроки, которые разделили места с 1 по 10.

Как ORDER BY влияет на ничьи

RANK и DENSE_RANK считают строки равными, если у них совпадает весь набор выражений в ORDER BY.

Например:

RANK() OVER (ORDER BY score DESC)

Если у двух строк одинаковый score, они получат одинаковый ранг.

Но если добавить второй критерий:

RANK() OVER (ORDER BY score DESC, created_at ASC)

то строки с одинаковым score, но разным created_at, уже не будут ничьей. У них разный набор значений сортировки, значит ранг может стать разным.

Это очень важный момент.

Допустим, два игрока набрали 90 очков:

name score created_at
Bob 90 2024-03-01
Vera 90 2024-03-05

Если сортировать только по очкам:

SELECT
  name,
  score,
  RANK() OVER (ORDER BY score DESC) AS place
FROM players;

Они получат одинаковое место.

Если сортировать по очкам и дате регистрации:

SELECT
  name,
  score,
  created_at,
  RANK() OVER (ORDER BY score DESC, created_at ASC) AS place
FROM players;

Тогда Bob может оказаться выше Vera, потому что зарегистрировался раньше. Ничья будет разбита вторым критерием.

Это не плохо и не хорошо. Это зависит от задачи.

Хочешь сохранять ничьи по очкам — сортируй только по score.
Хочешь разбивать ничьи дополнительным правилом — добавь второй столбец в ORDER BY.

RANK, DENSE_RANK и NULL

Если колонка, по которой ты сортируешь, содержит NULL, такие строки тоже получат ранг.

Но важно понимать, куда база положит NULL при сортировке.

В PostgreSQL по умолчанию:

  • при ASC значения NULL идут в конце;
  • при DESC значения NULL идут в начале.

Например:

SELECT
  name,
  score,
  RANK() OVER (ORDER BY score DESC) AS place
FROM players;

Если у кого-то score равен NULL, в PostgreSQL при DESC такие строки могут оказаться наверху. Обычно для рейтинга это не то, что нужно.

Лучше явно указать:

SELECT
  name,
  score,
  RANK() OVER (ORDER BY score DESC NULLS LAST) AS place
FROM players;

Теперь игроки без очков будут в конце рейтинга.

Для сортировки по возрастанию тоже можно управлять положением NULL:

SELECT
  name,
  race_time,
  RANK() OVER (ORDER BY race_time ASC NULLS LAST) AS place
FROM race_results;

Так участники без результата не окажутся среди лучших.

RANK без ORDER BY

Технически можно написать оконную функцию без ORDER BY:

SELECT
  name,
  score,
  RANK() OVER () AS place
FROM players;

Но для ранжирования это почти всегда бессмысленно.

Без ORDER BY база не понимает, кто лучше, а кто хуже. Все строки считаются равными с точки зрения ранга и получают 1.

Поэтому хорошая привычка простая: для RANK и DENSE_RANK всегда пиши ORDER BY внутри OVER.

RANK или ROW_NUMBER

Иногда кажется, что RANK и ROW_NUMBER делают одно и то же. На данных без одинаковых значений они действительно могут вернуть одинаковые числа.

Например:

name score
Anna 95
Bob 90
Vera 85

Тут и ROW_NUMBER, и RANK дадут 1, 2, 3.

Но как только появится ничья, разница станет заметной.

ROW_NUMBER нужен, когда тебе нужен один уникальный номер для каждой строки.

Например:

  • выбрать одну последнюю запись на пользователя;
  • пронумеровать строки для пагинации;
  • взять ровно 10 строк;
  • выбрать одну строку из группы по правилу.

RANK нужен, когда тебе важно сохранить одинаковое место для одинаковых значений.

Например:

  • рейтинг игроков;
  • места участников;
  • топ товаров по продажам с ничьими;
  • место клиента по сумме покупок.

DENSE_RANK для уникальных уровней

DENSE_RANK особенно удобен, когда нужно работать не со строками, а с уникальными значениями.

Например, есть сотрудники с зарплатами:

name salary
Anna 200000
Bob 180000
Vera 180000
Gregory 150000
Denis 120000

Нужно найти сотрудников со второй по величине зарплатой.

Через DENSE_RANK это получается очень читаемо:

WITH ranked_employees AS (
  SELECT
    name,
    salary,
    DENSE_RANK() OVER (ORDER BY salary DESC) AS salary_rank
  FROM employees
)
SELECT
  name,
  salary
FROM ranked_employees
WHERE salary_rank = 2;

Результат:

name salary
Bob 180000
Vera 180000

Почему не ROW_NUMBER? Потому что нам нужны все сотрудники со второй зарплатой, а не один человек, которому случайно достался номер 2.

Почему не RANK? В этом конкретном примере RANK тоже может сработать. Но если на первом уровне будет много сотрудников, ранг второй уникальной зарплаты может стать не 2, а, например, 5. А DENSE_RANK всегда даст второй уникальной зарплате ранг 2.

PERCENT_RANK и CUME_DIST

Кроме RANK и DENSE_RANK, в PostgreSQL есть и другие функции для анализа положения строки в распределении.

Например:

  • PERCENT_RANK показывает относительный ранг от 0 до 1;
  • CUME_DIST показывает накопленную долю строк до текущего значения.

Пример:

SELECT
  name,
  score,
  PERCENT_RANK() OVER (ORDER BY score DESC) AS percent_rank_value,
  CUME_DIST() OVER (ORDER BY score DESC) AS cume_dist_value
FROM scores;

Эти функции нужны реже, чем RANK и DENSE_RANK. Обычно они встречаются в аналитике, когда нужно понимать не просто место, а положение значения внутри распределения.

Например: входит ли студент в верхние 10% по баллам, насколько высокий чек относительно остальных клиентов, где товар находится в распределении цен.

Для начала достаточно уверенно понимать три базовые функции: ROW_NUMBER, RANK и DENSE_RANK.

Частые ошибки с RANK и DENSE_RANK

Путать ROW_NUMBER, RANK и DENSE_RANK

Запомни так:

ROW_NUMBER: 1, 2, 3, 4
RANK:       1, 2, 2, 4
DENSE_RANK: 1, 2, 2, 3

ROW_NUMBER — уникальный номер строки.
RANK — место в рейтинге с пропусками после ничьих.
DENSE_RANK — плотный уровень без пропусков.

Забывать ORDER BY внутри OVER

Плохо:

SELECT
  name,
  score,
  RANK() OVER () AS place
FROM players;

Лучше:

SELECT
  name,
  score,
  RANK() OVER (ORDER BY score DESC) AS place
FROM players;

Ранг должен быть по чему-то посчитан.

Случайно разбивать ничьи вторым полем

Если написать:

RANK() OVER (ORDER BY score DESC, created_at ASC)

то строки с одинаковым score, но разным created_at, получат разные ранги.

Если тебе нужно, чтобы одинаковые очки давали одинаковое место, оставь только score:

RANK() OVER (ORDER BY score DESC)

А если по правилам рейтинга более ранняя дата должна побеждать при равных очках, тогда второй критерий нужен.

Пытаться фильтровать ранг в WHERE

Так нельзя:

SELECT
  name,
  score,
  RANK() OVER (ORDER BY score DESC) AS place
FROM players
WHERE place <= 10;

Правильно через CTE:

WITH ranked_players AS (
  SELECT
    name,
    score,
    RANK() OVER (ORDER BY score DESC) AS place
  FROM players
)
SELECT
  name,
  score,
  place
FROM ranked_players
WHERE place <= 10;

Использовать LIMIT там, где нужны все участники с одинаковым местом

LIMIT 10 вернёт ровно 10 строк. Но если на десятом месте несколько участников, часть из них может не попасть в выдачу.

Если нужен честный топ по местам, лучше так:

WITH ranked_players AS (
  SELECT
    name,
    score,
    RANK() OVER (ORDER BY score DESC) AS place
  FROM players
)
SELECT
  name,
  score,
  place
FROM ranked_players
WHERE place <= 10;

Не учитывать NULL

Если значения NULL не должны участвовать в верхушке рейтинга, укажи это явно:

SELECT
  name,
  score,
  RANK() OVER (ORDER BY score DESC NULLS LAST) AS place
FROM players;

Так запрос читается честно: игроки без результата идут в конец.

Главное

RANK и DENSE_RANK нужны для ранжирования, когда в данных могут быть одинаковые значения.

Главные отличия:

ROW_NUMBER: 1, 2, 3, 4, 5
RANK:       1, 2, 2, 4, 5
DENSE_RANK: 1, 2, 2, 3, 4

Используй ROW_NUMBER, когда нужен уникальный номер каждой строки.

Используй RANK, когда нужны места в рейтинге с олимпийской логикой: если два участника делят второе место, следующий получает четвёртое.

Используй DENSE_RANK, когда нужны плотные уровни без пропусков: первый уровень, второй уровень, третий уровень.

Базовые шаблоны:

RANK() OVER (ORDER BY score DESC)
DENSE_RANK() OVER (ORDER BY price DESC)
RANK() OVER (
  PARTITION BY category
  ORDER BY price DESC
)

И главное правило: для фильтрации по рангу используй CTE или подзапрос. Оконные функции считаются после WHERE, поэтому напрямую в WHERE их результат использовать нельзя.

RANK и DENSE_RANK делают отчёты честнее. Они не притворяются, что одинаковые результаты разные. Если два человека, товара или заказа действительно на одном уровне, SQL может показать это прямо — аккуратно, понятно и без ручных костылей.

Закрепи на практике

Решай задачи в SQL-тренажёре с мгновенной проверкой и подсказками.

Открыть тренажёр