ROW_NUMBER — это оконная функция, которая нумерует строки в результате запроса.
Первая строка получает номер 1, вторая — 2, третья — 3, и так дальше. У каждой строки будет свой уникальный номер: без ничьих, без одинаковых мест, без пропусков.
Проще всего думать так: ROW_NUMBER берёт уже найденные строки, раскладывает их в нужном порядке и подписывает каждую строку порядковым номером.
Например:
| name |
amount |
row_num |
| Boris |
500 |
1 |
| Anna |
250 |
2 |
| Vera |
100 |
3 |
Такая нумерация очень полезна, когда нужно выбрать «первую строку в группе», «последние 3 заказа каждого клиента», «самую свежую запись по каждому email» или просто аккуратно пронумеровать результат.
ROW_NUMBER — одна из самых понятных оконных функций. С неё удобно начинать изучение окон: она быстро показывает главную идею, но не перегружает теорией.
Зачем нужен ROW_NUMBER
ROW_NUMBER нужен не для красоты. В реальных задачах он решает очень практичные проблемы.
Чаще всего его используют, когда нужно:
- получить топ строк внутри каждой группы;
- оставить одну строку из набора дублей;
- найти первый или последний заказ каждого клиента;
- выбрать самый дорогой товар в каждой категории;
- пронумеровать строки для отчёта;
- подготовить стабильную выборку для пагинации;
- понять порядок событий внутри истории пользователя.
Например, у клиента может быть много заказов. Нам нужен последний заказ каждого клиента. Обычный ORDER BY отсортирует все заказы целиком, но не выберет по одному заказу на клиента. А ROW_NUMBER умеет пронумеровать заказы отдельно внутри каждого клиента: самый новый получит 1, следующий — 2, и так дальше.
После этого остаётся выбрать строки, где номер равен 1.
Базовый синтаксис ROW_NUMBER
Общий вид такой:
ROW_NUMBER() OVER (
ORDER BY column_name
)
Разберём по частям.
ROW_NUMBER() — сама функция. У неё нет аргументов в скобках.
OVER (...) — обязательная часть для оконной функции. Она говорит SQL: «считай не обычную функцию по одной строке, а оконную функцию по набору строк».
ORDER BY внутри OVER задаёт порядок, в котором строки будут нумероваться.
Пример:
SELECT
id,
customer_id,
amount,
ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num
FROM orders;
Этот запрос означает:
Возьми заказы, отсортируй их по сумме от большей к меньшей и пронумеруй: самый дорогой заказ получит 1, следующий — 2, потом 3 и так дальше.
Пример: глобальная нумерация заказов
Допустим, есть таблица orders:
| id |
customer_id |
amount |
| 1 |
1 |
100 |
| 2 |
2 |
250 |
| 3 |
1 |
80 |
| 4 |
2 |
500 |
| 5 |
1 |
200 |
Пронумеруем все заказы по сумме от большей к меньшей:
SELECT
id,
customer_id,
amount,
ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num
FROM orders;
Результат:
| id |
customer_id |
amount |
row_num |
| 4 |
2 |
500 |
1 |
| 2 |
2 |
250 |
2 |
| 5 |
1 |
200 |
3 |
| 1 |
1 |
100 |
4 |
| 3 |
1 |
80 |
5 |
Что произошло?
SQL отсортировал строки по amount DESC. Самый большой заказ оказался первым и получил row_num = 1. Самый маленький оказался последним и получил row_num = 5.
Это называется глобальная нумерация: один общий список, одна общая последовательность номеров.
ORDER BY внутри OVER обязателен по смыслу
Технически в некоторых базах можно написать так:
SELECT
id,
ROW_NUMBER() OVER () AS row_num
FROM orders;
Но для реальной работы это плохая идея.
Без ORDER BY база не получает правила, в каком порядке нумеровать строки. Она может выдать номера так, как ей удобно в конкретный момент. После изменения данных, индекса или плана выполнения порядок может стать другим.
Поэтому хорошая привычка:
ROW_NUMBER() OVER (ORDER BY id)
или:
ROW_NUMBER() OVER (ORDER BY created_at DESC, id DESC)
Если номер зависит от порядка, порядок нужно описывать явно.
PARTITION BY: нумерация внутри групп
Самая сильная сторона ROW_NUMBER раскрывается вместе с PARTITION BY.
PARTITION BY делит строки на группы, а ROW_NUMBER начинает нумерацию заново внутри каждой группы.
Например:
SELECT
id,
customer_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY amount DESC
) AS row_num
FROM orders;
Здесь логика такая:
PARTITION BY customer_id делит заказы по клиентам.
ORDER BY amount DESC сортирует заказы внутри каждого клиента от дорогих к дешёвым.
ROW_NUMBER нумерует заказы каждого клиента с 1.
Результат:
| id |
customer_id |
amount |
row_num |
| 5 |
1 |
200 |
1 |
| 1 |
1 |
100 |
2 |
| 3 |
1 |
80 |
3 |
| 4 |
2 |
500 |
1 |
| 2 |
2 |
250 |
2 |
Обрати внимание: row_num = 1 есть у каждого клиента.
Для клиента 1 самый дорогой заказ — 200.
Для клиента 2 самый дорогой заказ — 500.
Это не ошибка. Это именно то, что делает PARTITION BY: создаёт отдельную нумерацию внутри каждой группы.
Простая аналогия
Представь соревнования в нескольких школах.
Если нумеровать всех учеников вместе, получится общий рейтинг по городу: 1, 2, 3, 4, 5.
А если нумеровать внутри каждой школы, то в каждой школе будет свой первый ученик, свой второй ученик, свой третий ученик.
Вот это и делает PARTITION BY.
Без PARTITION BY:
ROW_NUMBER() OVER (ORDER BY score DESC)
Один общий рейтинг.
С PARTITION BY:
ROW_NUMBER() OVER (
PARTITION BY school_id
ORDER BY score DESC
)
Отдельный рейтинг внутри каждой школы.
Top-N в каждой группе
Самый частый сценарий для ROW_NUMBER — получить топ строк в каждой группе.
Например:
Найти 3 самых дорогих заказа каждого клиента.
Сначала пронумеруем заказы внутри каждого клиента:
WITH ranked_orders AS (
SELECT
id,
customer_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY amount DESC, id DESC
) AS row_num
FROM orders
)
SELECT
id,
customer_id,
amount
FROM ranked_orders
WHERE row_num <= 3;
Что здесь происходит:
- В CTE
ranked_orders мы добавляем к каждому заказу номер внутри клиента.
- Самый дорогой заказ клиента получает
row_num = 1.
- Второй по сумме получает
row_num = 2.
- Третий получает
row_num = 3.
- Внешний запрос оставляет только строки с
row_num <= 3.
Так можно решать много похожих задач:
- последние 5 сообщений каждого пользователя;
- топ-3 товара в каждой категории;
- самый дорогой заказ каждого клиента;
- первая покупка каждого клиента;
- последняя активность каждого аккаунта.
Шаблон почти всегда один:
WITH ranked_rows AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY group_column
ORDER BY sort_column DESC
) AS row_num
FROM table_name
)
SELECT *
FROM ranked_rows
WHERE row_num <= 3;
Почему ROW_NUMBER нельзя использовать прямо в WHERE
Новички часто пытаются написать так:
SELECT
id,
customer_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY amount DESC
) AS row_num
FROM orders
WHERE row_num = 1;
Такой запрос не сработает.
Причина в порядке выполнения SQL-запроса. WHERE фильтрует строки раньше, чем считаются оконные функции. В момент, когда база обрабатывает WHERE, колонки row_num ещё не существует.
Поэтому нужен подзапрос или CTE.
Через CTE:
WITH ranked_orders AS (
SELECT
id,
customer_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY amount DESC
) AS row_num
FROM orders
)
SELECT
id,
customer_id,
amount
FROM ranked_orders
WHERE row_num = 1;
Через подзапрос:
SELECT
id,
customer_id,
amount
FROM (
SELECT
id,
customer_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY amount DESC
) AS row_num
FROM orders
) AS ranked_orders
WHERE row_num = 1;
Оба варианта правильные. Для учебных и рабочих запросов CTE часто читается приятнее.
Пример: последний заказ каждого клиента
Допустим, есть таблица orders:
| id |
customer_id |
amount |
created_at |
| 1 |
1 |
100 |
2024-03-01 |
| 2 |
1 |
200 |
2024-03-10 |
| 3 |
2 |
500 |
2024-03-05 |
| 4 |
2 |
300 |
2024-03-20 |
| 5 |
3 |
150 |
2024-03-15 |
Нужно получить последний заказ каждого клиента.
WITH ranked_orders AS (
SELECT
id,
customer_id,
amount,
created_at,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC, id DESC
) AS row_num
FROM orders
)
SELECT
id,
customer_id,
amount,
created_at
FROM ranked_orders
WHERE row_num = 1;
Результат:
| id |
customer_id |
amount |
created_at |
| 2 |
1 |
200 |
2024-03-10 |
| 4 |
2 |
300 |
2024-03-20 |
| 5 |
3 |
150 |
2024-03-15 |
PARTITION BY customer_id создал отдельную историю заказов для каждого клиента.
ORDER BY created_at DESC, id DESC поставил самые свежие заказы выше старых.
row_num = 1 оставил только первый заказ в каждой истории.
Зачем добавлять id в ORDER BY
В примере выше сортировка такая:
ORDER BY created_at DESC, id DESC
Почему не просто так?
ORDER BY created_at DESC
Потому что у двух заказов может быть одинаковая дата и время создания. Если сортировать только по created_at, база не всегда сможет однозначно решить, какой из них первый.
Когда мы добавляем id DESC, порядок становится стабильнее:
- сначала сортируем по дате;
- если даты одинаковые, выше будет строка с большим
id.
Такой дополнительный критерий называют tie-breaker — правило для разбора ничьей.
Для ROW_NUMBER это особенно важно. Эта функция всегда должна выдать уникальный номер каждой строке. Если порядок не полностью определён, номер может достаться разным строкам в разных запусках.
Хорошая привычка: если сортируешь по неуникальной колонке, добавляй в конец уникальный ключ.
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC, id DESC
)
Удаление дублей через ROW_NUMBER
ROW_NUMBER часто используют для очистки данных.
Представь таблицу users, куда из-за старого импорта попали дубли по email.
Нужно оставить самую свежую строку для каждого email, а старые дубли удалить.
Сначала посмотрим, как пронумеровать строки:
SELECT
id,
email,
created_at,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY created_at DESC, id DESC
) AS row_num
FROM users;
Результат:
Теперь логика понятна:
row_num = 1 — строка, которую хотим оставить;
row_num > 1 — дубли, которые можно удалить.
Запрос на удаление в PostgreSQL может выглядеть так:
WITH ranked_users AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY created_at DESC, id DESC
) AS row_num
FROM users
)
DELETE FROM users
WHERE id IN (
SELECT id
FROM ranked_users
WHERE row_num > 1
);
Это классический паттерн dedup: сгруппировать по ключу дубля, отсортировать по свежести, оставить первую строку.
Перед удалением лучше всегда сначала выполнить проверочный SELECT:
WITH ranked_users AS (
SELECT
id,
email,
created_at,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY created_at DESC, id DESC
) AS row_num
FROM users
)
SELECT
id,
email,
created_at,
row_num
FROM ranked_users
WHERE row_num > 1;
Так ты увидишь, какие строки будут считаться дублями.
ROW_NUMBER и пагинация
ROW_NUMBER можно использовать для пагинации, когда нужно присвоить строкам номера и потом взять нужный диапазон.
Например, получить строки с 11-й по 20-ю:
WITH numbered_products AS (
SELECT
id,
title,
price,
ROW_NUMBER() OVER (
ORDER BY price ASC, id ASC
) AS row_num
FROM products
)
SELECT
id,
title,
price
FROM numbered_products
WHERE row_num BETWEEN 11 AND 20
ORDER BY row_num;
Такой подход делает страницу понятной: сначала мы определили стабильный порядок товаров, потом взяли нужный диапазон номеров.
В современных приложениях часто используют LIMIT и OFFSET или keyset pagination, но ROW_NUMBER всё равно полезен для отчётов, выгрузок и сложных запросов.
Главное правило то же: порядок должен быть стабильным. Поэтому в сортировку добавлен id ASC.
ROW_NUMBER и обычный ORDER BY в конце запроса
Важно различать два разных ORDER BY.
Первый — внутри OVER:
ROW_NUMBER() OVER (
ORDER BY amount DESC
)
Он отвечает за то, в каком порядке выдавать номера.
Второй — в конце запроса:
ORDER BY amount DESC
Он отвечает за порядок строк в итоговой выдаче.
Например:
SELECT
id,
amount,
ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num
FROM orders;
Этот запрос посчитает номера по сумме, но итоговая выдача не обязана быть отсортирована так же во всех базах и во всех ситуациях.
Если тебе важно, чтобы результат на экране шёл по номеру, добавь внешний ORDER BY:
SELECT
id,
amount,
ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num
FROM orders
ORDER BY row_num;
Или так:
SELECT
id,
amount,
ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num
FROM orders
ORDER BY amount DESC;
Для учебных примеров разница может быть незаметной. Но в рабочих запросах лучше явно сортировать итоговый результат, если порядок важен.
ROW_NUMBER, RANK и DENSE_RANK
ROW_NUMBER, RANK и DENSE_RANK похожи: все три функции связаны с нумерацией и рейтингами.
Но они по-разному обрабатывают одинаковые значения.
Допустим, есть результаты игроков:
| name |
score |
| Anna |
95 |
| Bob |
90 |
| Vera |
90 |
| Gregory |
85 |
Запрос:
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 |
Разница такая:
ROW_NUMBER всегда выдаёт уникальный номер: 1, 2, 3, 4;
RANK даёт одинаковый ранг одинаковым значениям, но потом делает пропуск: 1, 2, 2, 4;
DENSE_RANK даёт одинаковый ранг одинаковым значениям без пропуска: 1, 2, 2, 3.
Когда нужен ровно один победитель или одна строка из группы, используй ROW_NUMBER.
Когда нужно честно показать одинаковые места в рейтинге, используй RANK или DENSE_RANK.
Когда ROW_NUMBER подходит лучше всего
ROW_NUMBER хорош, когда тебе нужно выбрать одну конкретную строку из группы по понятному правилу.
Например:
- один последний заказ каждого клиента;
- одна самая свежая запись по email;
- один самый дорогой товар в категории;
- одна последняя попытка прохождения теста;
- одна актуальная версия документа.
Пример: выбрать самую свежую попытку каждого пользователя.
WITH ranked_attempts AS (
SELECT
id,
user_id,
score,
finished_at,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY finished_at DESC, id DESC
) AS row_num
FROM attempts
)
SELECT
id,
user_id,
score,
finished_at
FROM ranked_attempts
WHERE row_num = 1;
Здесь ROW_NUMBER идеально подходит, потому что нам нужна ровно одна строка на пользователя.
Если же по правилам нужно сохранить всех пользователей с одинаковым лучшим результатом, лучше смотреть в сторону RANK.
ROW_NUMBER не является постоянным id
Очень важный момент: row_num — это не настоящий идентификатор строки.
Он вычисляется каждый раз заново при выполнении запроса.
Сегодня строка получила номер 5. Завтра в таблицу добавили новую строку, изменилась сортировка — и старая строка может получить номер 6.
Поэтому нельзя использовать ROW_NUMBER как постоянный id в базе.
Плохо думать так:
ROW_NUMBER() OVER (ORDER BY created_at) AS generated_id
Это не стабильный идентификатор. Это просто номер строки в конкретном результате конкретного запроса.
Для постоянных идентификаторов используй обычные ключи таблицы: id, uuid или другие значения, которые хранятся в данных.
Производительность: что важно понимать новичку
ROW_NUMBER сначала должен отсортировать строки внутри окна, а потом присвоить номера.
Если данных много, сортировка может быть дорогой. Особенно если ты нумеруешь миллионы строк внутри больших групп.
Например:
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
)
Такой запрос может быть тяжёлым, если у одного клиента миллионы заказов.
Что помогает:
- индекс по колонкам, которые участвуют в группировке и сортировке;
- аккуратный
WHERE, чтобы заранее ограничить набор строк;
- стабильный и понятный
ORDER BY;
- понимание, что для простого top-1 в PostgreSQL иногда бывает удобен
DISTINCT ON.
Пример PostgreSQL-варианта для последнего заказа каждого клиента:
SELECT DISTINCT ON (customer_id)
id,
customer_id,
amount,
created_at
FROM orders
ORDER BY customer_id, created_at DESC, id DESC;
Это уже более продвинутый приём. Для начала достаточно уверенно владеть вариантом с ROW_NUMBER, потому что он понятный, универсальный и хорошо переносится между задачами.
Частые ошибки с ROW_NUMBER
Использовать ROW_NUMBER в WHERE без CTE или подзапроса
Так нельзя:
SELECT
id,
ROW_NUMBER() OVER (ORDER BY id) AS row_num
FROM users
WHERE row_num = 1;
Правильно так:
WITH numbered_users AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY id) AS row_num
FROM users
)
SELECT
id
FROM numbered_users
WHERE row_num = 1;
Сначала считаем номер, потом фильтруем.
Забывать ORDER BY внутри OVER
Плохо:
SELECT
id,
ROW_NUMBER() OVER () AS row_num
FROM orders;
Лучше:
SELECT
id,
ROW_NUMBER() OVER (ORDER BY id) AS row_num
FROM orders;
Если нужен осмысленный номер, нужен осмысленный порядок.
Сортировать по неуникальному полю без дополнительного критерия
Менее надёжно:
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
)
Надёжнее:
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC, id DESC
)
Если у двух строк одинаковый created_at, id поможет выбрать порядок предсказуемо.
Забыть PARTITION BY
Допустим, нужно получить последний заказ каждого клиента.
Ошибка:
ROW_NUMBER() OVER (
ORDER BY created_at DESC
)
Так ты получишь общий номер заказа во всей таблице.
Правильно:
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
)
Теперь нумерация начнётся заново для каждого клиента.
Думать, что ROW_NUMBER обрабатывает ничьи
ROW_NUMBER не сохраняет ничьи. Он всё равно выдаёт разным строкам разные номера.
Если две строки имеют одинаковый результат, но должны разделить одно место, нужен не ROW_NUMBER, а RANK или DENSE_RANK.
Считать row_num постоянным значением
row_num существует только в результате запроса. Он не хранится в таблице и может измениться при следующем запуске.
Это номер в текущей выдаче, а не свойство строки.
Главное
ROW_NUMBER — это оконная функция, которая выдаёт каждой строке уникальный порядковый номер.
Базовый шаблон:
ROW_NUMBER() OVER (
ORDER BY sort_column
)
Нумерация внутри групп:
ROW_NUMBER() OVER (
PARTITION BY group_column
ORDER BY sort_column DESC
)
Классический top-N по группам:
WITH ranked_rows AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY group_column
ORDER BY sort_column DESC
) AS row_num
FROM table_name
)
SELECT *
FROM ranked_rows
WHERE row_num <= 3;
Главные правила:
ROW_NUMBER всегда выдаёт уникальный номер каждой строке;
ORDER BY внутри OVER задаёт порядок нумерации;
PARTITION BY запускает нумерацию заново внутри каждой группы;
- фильтровать по
row_num нужно во внешнем запросе, через CTE или подзапрос;
- если в сортировке возможны одинаковые значения, добавляй дополнительный критерий, например
id;
ROW_NUMBER не заменяет постоянный id;
- если одинаковые значения должны получить одинаковый ранг, используй
RANK или DENSE_RANK.
ROW_NUMBER — это рабочая лошадка оконных функций. Он прост по форме, но закрывает десятки реальных задач: топы по группам, последние записи, очистку дублей, выбор актуальных строк и аккуратную нумерацию отчётов. Когда начинаешь уверенно пользоваться ROW_NUMBER, оконные функции перестают казаться страшными и становятся обычным инструментом хорошего SQL.
ROW_NUMBER— это оконная функция, которая нумерует строки в результате запроса.Первая строка получает номер 1, вторая — 2, третья — 3, и так дальше. У каждой строки будет свой уникальный номер: без ничьих, без одинаковых мест, без пропусков.
Проще всего думать так:
ROW_NUMBERберёт уже найденные строки, раскладывает их в нужном порядке и подписывает каждую строку порядковым номером.Например:
Такая нумерация очень полезна, когда нужно выбрать «первую строку в группе», «последние 3 заказа каждого клиента», «самую свежую запись по каждому email» или просто аккуратно пронумеровать результат.
ROW_NUMBER— одна из самых понятных оконных функций. С неё удобно начинать изучение окон: она быстро показывает главную идею, но не перегружает теорией.Зачем нужен ROW_NUMBER
ROW_NUMBERнужен не для красоты. В реальных задачах он решает очень практичные проблемы.Чаще всего его используют, когда нужно:
Например, у клиента может быть много заказов. Нам нужен последний заказ каждого клиента. Обычный
ORDER BYотсортирует все заказы целиком, но не выберет по одному заказу на клиента. АROW_NUMBERумеет пронумеровать заказы отдельно внутри каждого клиента: самый новый получит 1, следующий — 2, и так дальше.После этого остаётся выбрать строки, где номер равен 1.
Базовый синтаксис ROW_NUMBER
Общий вид такой:
ROW_NUMBER() OVER ( ORDER BY column_name )Разберём по частям.
ROW_NUMBER()— сама функция. У неё нет аргументов в скобках.OVER (...)— обязательная часть для оконной функции. Она говорит SQL: «считай не обычную функцию по одной строке, а оконную функцию по набору строк».ORDER BYвнутриOVERзадаёт порядок, в котором строки будут нумероваться.Пример:
SELECT id, customer_id, amount, ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num FROM orders;Этот запрос означает:
Пример: глобальная нумерация заказов
Допустим, есть таблица
orders:Пронумеруем все заказы по сумме от большей к меньшей:
SELECT id, customer_id, amount, ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num FROM orders;Результат:
Что произошло?
SQL отсортировал строки по
amount DESC. Самый большой заказ оказался первым и получилrow_num = 1. Самый маленький оказался последним и получилrow_num = 5.Это называется глобальная нумерация: один общий список, одна общая последовательность номеров.
ORDER BY внутри OVER обязателен по смыслу
Технически в некоторых базах можно написать так:
SELECT id, ROW_NUMBER() OVER () AS row_num FROM orders;Но для реальной работы это плохая идея.
Без
ORDER BYбаза не получает правила, в каком порядке нумеровать строки. Она может выдать номера так, как ей удобно в конкретный момент. После изменения данных, индекса или плана выполнения порядок может стать другим.Поэтому хорошая привычка:
ROW_NUMBER() OVER (ORDER BY id)или:
ROW_NUMBER() OVER (ORDER BY created_at DESC, id DESC)Если номер зависит от порядка, порядок нужно описывать явно.
PARTITION BY: нумерация внутри групп
Самая сильная сторона
ROW_NUMBERраскрывается вместе сPARTITION BY.PARTITION BYделит строки на группы, аROW_NUMBERначинает нумерацию заново внутри каждой группы.Например:
SELECT id, customer_id, amount, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY amount DESC ) AS row_num FROM orders;Здесь логика такая:
PARTITION BY customer_idделит заказы по клиентам.ORDER BY amount DESCсортирует заказы внутри каждого клиента от дорогих к дешёвым.ROW_NUMBERнумерует заказы каждого клиента с 1.Результат:
Обрати внимание:
row_num = 1есть у каждого клиента.Для клиента 1 самый дорогой заказ — 200.
Для клиента 2 самый дорогой заказ — 500.
Это не ошибка. Это именно то, что делает
PARTITION BY: создаёт отдельную нумерацию внутри каждой группы.Простая аналогия
Представь соревнования в нескольких школах.
Если нумеровать всех учеников вместе, получится общий рейтинг по городу: 1, 2, 3, 4, 5.
А если нумеровать внутри каждой школы, то в каждой школе будет свой первый ученик, свой второй ученик, свой третий ученик.
Вот это и делает
PARTITION BY.Без
PARTITION BY:ROW_NUMBER() OVER (ORDER BY score DESC)Один общий рейтинг.
С
PARTITION BY:ROW_NUMBER() OVER ( PARTITION BY school_id ORDER BY score DESC )Отдельный рейтинг внутри каждой школы.
Top-N в каждой группе
Самый частый сценарий для
ROW_NUMBER— получить топ строк в каждой группе.Например:
Сначала пронумеруем заказы внутри каждого клиента:
WITH ranked_orders AS ( SELECT id, customer_id, amount, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY amount DESC, id DESC ) AS row_num FROM orders ) SELECT id, customer_id, amount FROM ranked_orders WHERE row_num <= 3;Что здесь происходит:
ranked_ordersмы добавляем к каждому заказу номер внутри клиента.row_num = 1.row_num = 2.row_num = 3.row_num <= 3.Так можно решать много похожих задач:
Шаблон почти всегда один:
WITH ranked_rows AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY group_column ORDER BY sort_column DESC ) AS row_num FROM table_name ) SELECT * FROM ranked_rows WHERE row_num <= 3;Почему ROW_NUMBER нельзя использовать прямо в WHERE
Новички часто пытаются написать так:
SELECT id, customer_id, amount, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY amount DESC ) AS row_num FROM orders WHERE row_num = 1;Такой запрос не сработает.
Причина в порядке выполнения SQL-запроса.
WHEREфильтрует строки раньше, чем считаются оконные функции. В момент, когда база обрабатываетWHERE, колонкиrow_numещё не существует.Поэтому нужен подзапрос или CTE.
Через CTE:
WITH ranked_orders AS ( SELECT id, customer_id, amount, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY amount DESC ) AS row_num FROM orders ) SELECT id, customer_id, amount FROM ranked_orders WHERE row_num = 1;Через подзапрос:
SELECT id, customer_id, amount FROM ( SELECT id, customer_id, amount, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY amount DESC ) AS row_num FROM orders ) AS ranked_orders WHERE row_num = 1;Оба варианта правильные. Для учебных и рабочих запросов CTE часто читается приятнее.
Пример: последний заказ каждого клиента
Допустим, есть таблица
orders:Нужно получить последний заказ каждого клиента.
WITH ranked_orders AS ( SELECT id, customer_id, amount, created_at, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC, id DESC ) AS row_num FROM orders ) SELECT id, customer_id, amount, created_at FROM ranked_orders WHERE row_num = 1;Результат:
PARTITION BY customer_idсоздал отдельную историю заказов для каждого клиента.ORDER BY created_at DESC, id DESCпоставил самые свежие заказы выше старых.row_num = 1оставил только первый заказ в каждой истории.Зачем добавлять id в ORDER BY
В примере выше сортировка такая:
ORDER BY created_at DESC, id DESCПочему не просто так?
ORDER BY created_at DESCПотому что у двух заказов может быть одинаковая дата и время создания. Если сортировать только по
created_at, база не всегда сможет однозначно решить, какой из них первый.Когда мы добавляем
id DESC, порядок становится стабильнее:id.Такой дополнительный критерий называют tie-breaker — правило для разбора ничьей.
Для
ROW_NUMBERэто особенно важно. Эта функция всегда должна выдать уникальный номер каждой строке. Если порядок не полностью определён, номер может достаться разным строкам в разных запусках.Хорошая привычка: если сортируешь по неуникальной колонке, добавляй в конец уникальный ключ.
ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC, id DESC )Удаление дублей через ROW_NUMBER
ROW_NUMBERчасто используют для очистки данных.Представь таблицу
users, куда из-за старого импорта попали дубли по email.Нужно оставить самую свежую строку для каждого email, а старые дубли удалить.
Сначала посмотрим, как пронумеровать строки:
SELECT id, email, created_at, ROW_NUMBER() OVER ( PARTITION BY email ORDER BY created_at DESC, id DESC ) AS row_num FROM users;Результат:
Теперь логика понятна:
row_num = 1— строка, которую хотим оставить;row_num > 1— дубли, которые можно удалить.Запрос на удаление в PostgreSQL может выглядеть так:
WITH ranked_users AS ( SELECT id, ROW_NUMBER() OVER ( PARTITION BY email ORDER BY created_at DESC, id DESC ) AS row_num FROM users ) DELETE FROM users WHERE id IN ( SELECT id FROM ranked_users WHERE row_num > 1 );Это классический паттерн dedup: сгруппировать по ключу дубля, отсортировать по свежести, оставить первую строку.
Перед удалением лучше всегда сначала выполнить проверочный
SELECT:WITH ranked_users AS ( SELECT id, email, created_at, ROW_NUMBER() OVER ( PARTITION BY email ORDER BY created_at DESC, id DESC ) AS row_num FROM users ) SELECT id, email, created_at, row_num FROM ranked_users WHERE row_num > 1;Так ты увидишь, какие строки будут считаться дублями.
ROW_NUMBER и пагинация
ROW_NUMBERможно использовать для пагинации, когда нужно присвоить строкам номера и потом взять нужный диапазон.Например, получить строки с 11-й по 20-ю:
WITH numbered_products AS ( SELECT id, title, price, ROW_NUMBER() OVER ( ORDER BY price ASC, id ASC ) AS row_num FROM products ) SELECT id, title, price FROM numbered_products WHERE row_num BETWEEN 11 AND 20 ORDER BY row_num;Такой подход делает страницу понятной: сначала мы определили стабильный порядок товаров, потом взяли нужный диапазон номеров.
В современных приложениях часто используют
LIMITиOFFSETили keyset pagination, ноROW_NUMBERвсё равно полезен для отчётов, выгрузок и сложных запросов.Главное правило то же: порядок должен быть стабильным. Поэтому в сортировку добавлен
id ASC.ROW_NUMBER и обычный ORDER BY в конце запроса
Важно различать два разных
ORDER BY.Первый — внутри
OVER:ROW_NUMBER() OVER ( ORDER BY amount DESC )Он отвечает за то, в каком порядке выдавать номера.
Второй — в конце запроса:
ORDER BY amount DESCОн отвечает за порядок строк в итоговой выдаче.
Например:
SELECT id, amount, ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num FROM orders;Этот запрос посчитает номера по сумме, но итоговая выдача не обязана быть отсортирована так же во всех базах и во всех ситуациях.
Если тебе важно, чтобы результат на экране шёл по номеру, добавь внешний
ORDER BY:SELECT id, amount, ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num FROM orders ORDER BY row_num;Или так:
SELECT id, amount, ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num FROM orders ORDER BY amount DESC;Для учебных примеров разница может быть незаметной. Но в рабочих запросах лучше явно сортировать итоговый результат, если порядок важен.
ROW_NUMBER, RANK и DENSE_RANK
ROW_NUMBER,RANKиDENSE_RANKпохожи: все три функции связаны с нумерацией и рейтингами.Но они по-разному обрабатывают одинаковые значения.
Допустим, есть результаты игроков:
Запрос:
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всегда выдаёт уникальный номер: 1, 2, 3, 4;RANKдаёт одинаковый ранг одинаковым значениям, но потом делает пропуск: 1, 2, 2, 4;DENSE_RANKдаёт одинаковый ранг одинаковым значениям без пропуска: 1, 2, 2, 3.Когда нужен ровно один победитель или одна строка из группы, используй
ROW_NUMBER.Когда нужно честно показать одинаковые места в рейтинге, используй
RANKилиDENSE_RANK.Когда ROW_NUMBER подходит лучше всего
ROW_NUMBERхорош, когда тебе нужно выбрать одну конкретную строку из группы по понятному правилу.Например:
Пример: выбрать самую свежую попытку каждого пользователя.
WITH ranked_attempts AS ( SELECT id, user_id, score, finished_at, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY finished_at DESC, id DESC ) AS row_num FROM attempts ) SELECT id, user_id, score, finished_at FROM ranked_attempts WHERE row_num = 1;Здесь
ROW_NUMBERидеально подходит, потому что нам нужна ровно одна строка на пользователя.Если же по правилам нужно сохранить всех пользователей с одинаковым лучшим результатом, лучше смотреть в сторону
RANK.ROW_NUMBER не является постоянным id
Очень важный момент:
row_num— это не настоящий идентификатор строки.Он вычисляется каждый раз заново при выполнении запроса.
Сегодня строка получила номер 5. Завтра в таблицу добавили новую строку, изменилась сортировка — и старая строка может получить номер 6.
Поэтому нельзя использовать
ROW_NUMBERкак постоянныйidв базе.Плохо думать так:
ROW_NUMBER() OVER (ORDER BY created_at) AS generated_idЭто не стабильный идентификатор. Это просто номер строки в конкретном результате конкретного запроса.
Для постоянных идентификаторов используй обычные ключи таблицы:
id,uuidили другие значения, которые хранятся в данных.Производительность: что важно понимать новичку
ROW_NUMBERсначала должен отсортировать строки внутри окна, а потом присвоить номера.Если данных много, сортировка может быть дорогой. Особенно если ты нумеруешь миллионы строк внутри больших групп.
Например:
ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC )Такой запрос может быть тяжёлым, если у одного клиента миллионы заказов.
Что помогает:
WHERE, чтобы заранее ограничить набор строк;ORDER BY;DISTINCT ON.Пример PostgreSQL-варианта для последнего заказа каждого клиента:
SELECT DISTINCT ON (customer_id) id, customer_id, amount, created_at FROM orders ORDER BY customer_id, created_at DESC, id DESC;Это уже более продвинутый приём. Для начала достаточно уверенно владеть вариантом с
ROW_NUMBER, потому что он понятный, универсальный и хорошо переносится между задачами.Частые ошибки с ROW_NUMBER
Использовать ROW_NUMBER в WHERE без CTE или подзапроса
Так нельзя:
SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS row_num FROM users WHERE row_num = 1;Правильно так:
WITH numbered_users AS ( SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS row_num FROM users ) SELECT id FROM numbered_users WHERE row_num = 1;Сначала считаем номер, потом фильтруем.
Забывать ORDER BY внутри OVER
Плохо:
SELECT id, ROW_NUMBER() OVER () AS row_num FROM orders;Лучше:
SELECT id, ROW_NUMBER() OVER (ORDER BY id) AS row_num FROM orders;Если нужен осмысленный номер, нужен осмысленный порядок.
Сортировать по неуникальному полю без дополнительного критерия
Менее надёжно:
ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC )Надёжнее:
ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC, id DESC )Если у двух строк одинаковый
created_at,idпоможет выбрать порядок предсказуемо.Забыть PARTITION BY
Допустим, нужно получить последний заказ каждого клиента.
Ошибка:
ROW_NUMBER() OVER ( ORDER BY created_at DESC )Так ты получишь общий номер заказа во всей таблице.
Правильно:
ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC )Теперь нумерация начнётся заново для каждого клиента.
Думать, что ROW_NUMBER обрабатывает ничьи
ROW_NUMBERне сохраняет ничьи. Он всё равно выдаёт разным строкам разные номера.Если две строки имеют одинаковый результат, но должны разделить одно место, нужен не
ROW_NUMBER, аRANKилиDENSE_RANK.Считать row_num постоянным значением
row_numсуществует только в результате запроса. Он не хранится в таблице и может измениться при следующем запуске.Это номер в текущей выдаче, а не свойство строки.
Главное
ROW_NUMBER— это оконная функция, которая выдаёт каждой строке уникальный порядковый номер.Базовый шаблон:
ROW_NUMBER() OVER ( ORDER BY sort_column )Нумерация внутри групп:
ROW_NUMBER() OVER ( PARTITION BY group_column ORDER BY sort_column DESC )Классический top-N по группам:
WITH ranked_rows AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY group_column ORDER BY sort_column DESC ) AS row_num FROM table_name ) SELECT * FROM ranked_rows WHERE row_num <= 3;Главные правила:
ROW_NUMBERвсегда выдаёт уникальный номер каждой строке;ORDER BYвнутриOVERзадаёт порядок нумерации;PARTITION BYзапускает нумерацию заново внутри каждой группы;row_numнужно во внешнем запросе, через CTE или подзапрос;id;ROW_NUMBERне заменяет постоянныйid;RANKилиDENSE_RANK.ROW_NUMBER— это рабочая лошадка оконных функций. Он прост по форме, но закрывает десятки реальных задач: топы по группам, последние записи, очистку дублей, выбор актуальных строк и аккуратную нумерацию отчётов. Когда начинаешь уверенно пользоватьсяROW_NUMBER, оконные функции перестают казаться страшными и становятся обычным инструментом хорошего SQL.