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 думает иначе: «Мне важны не занятые позиции, а уровни результата».
Уровни такие:
- 95 очков;
- 90 очков;
- 85 очков;
- 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 ранга ещё нет.
Поэтому мы делаем в два шага:
- Внутри
ranked_products считаем ранг.
- Снаружи фильтруем уже готовый результат.
Почему нельзя использовать 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 может показать это прямо — аккуратно, понятно и без ручных костылей.
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_NUMBERRANKDENSE_RANKРазница кажется маленькой, но в отчётах она очень важна.
Олимпийская аналогия
Представь соревнование.
Участники набрали очки:
Anna на первом месте. Bob и Vera набрали одинаково, значит оба делят второе место.
Что будет дальше?
Если использовать олимпийскую логику, Gregory будет не на третьем месте, а на четвёртом. Потому что второе место заняли два человека: условно позиции 2 и 3 заняты, следующий получает 4.
Это логика
RANK:А
DENSE_RANKдумает иначе: «Мне важны не занятые позиции, а уровни результата».Уровни такие:
Поэтому результат будет:
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;Результат:
Теперь разберём спокойно.
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лучше подходит, когда тебе нужны плотные уровни без пропусков.Например:
Допустим, есть товары с ценами:
Запрос:
SELECT name, price, DENSE_RANK() OVER (ORDER BY price DESC) AS price_tier FROM products;Результат:
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 товара в каждой категории
Одна из самых частых задач:
Для этого сначала считаем ранг в 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ранга ещё нет.Поэтому мы делаем в два шага:
ranked_productsсчитаем ранг.Почему нельзя использовать 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 очков:
Если сортировать только по очкам:
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делают одно и то же. На данных без одинаковых значений они действительно могут вернуть одинаковые числа.Например:
Тут и
ROW_NUMBER, иRANKдадут 1, 2, 3.Но как только появится ничья, разница станет заметной.
ROW_NUMBERнужен, когда тебе нужен один уникальный номер для каждой строки.Например:
RANKнужен, когда тебе важно сохранить одинаковое место для одинаковых значений.Например:
DENSE_RANK для уникальных уровней
DENSE_RANKособенно удобен, когда нужно работать не со строками, а с уникальными значениями.Например, есть сотрудники с зарплатами:
Нужно найти сотрудников со второй по величине зарплатой.
Через
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;Результат:
Почему не
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— уникальный номер строки.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, когда нужен уникальный номер каждой строки.Используй
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 может показать это прямо — аккуратно, понятно и без ручных костылей.