ROW_NUMBER — это оконная функция, которая нумерует строки по порядку. Первая получает 1, вторая — 2, и так далее. Никаких ничьих, никаких пропусков — у каждой строки свой уникальный номер.
Это самая простая из оконных функций, и обычно с неё начинают изучение «окон». Поэтому если ты когда-нибудь слышал, что «оконные функции — это сложно», начни с ROW_NUMBER. На нём всё становится понятно.
Зачем нужен ROW_NUMBER
Главные сценарии:
- Топ-N в каждой группе: «последние 3 заказа каждого клиента», «топ-1 продажа по каждой категории».
- Удаление дубликатов: «оставить только самую свежую строку для каждого email».
- Пагинация со стабильной сортировкой.
До оконных функций такие задачи решались через коррелированные подзапросы или хитрые self-join — длинно и медленно. С ROW_NUMBER это пара строк.
Базовый синтаксис
ROW_NUMBER() OVER (ORDER BY column [DESC])
ROW_NUMBER() — функция (без аргументов).
OVER (...) — обязательно, говорит «это оконная функция».
ORDER BY — в каком порядке нумеровать.
SELECT
name,
amount,
ROW_NUMBER() OVER (ORDER BY amount DESC) AS rn
FROM orders;
ROW_NUMBER() OVER (ORDER BY amount DESC) — пронумеруй строки по убыванию суммы. Самый дорогой получит rn = 1, следующий — rn = 2.
Пример с таблицей
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 rn
FROM orders;
Результат:
| id |
customer_id |
amount |
rn |
| 4 |
2 |
500 |
1 |
| 2 |
2 |
250 |
2 |
| 5 |
1 |
200 |
3 |
| 1 |
1 |
100 |
4 |
| 3 |
1 |
80 |
5 |
PARTITION BY — нумерация внутри групп
Теперь самое важное. ROW_NUMBER умеет нумеровать внутри групп:
SELECT
id, customer_id, amount,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS rn
FROM orders;
PARTITION BY customer_id — раздели строки по клиенту. ORDER BY amount DESC — внутри каждого клиента отсортируй по убыванию. ROW_NUMBER — пронумеруй с 1 заново в каждой группе.
Результат:
| id |
customer_id |
amount |
rn |
| 4 |
2 |
500 |
1 |
| 2 |
2 |
250 |
2 |
| 5 |
1 |
200 |
1 |
| 1 |
1 |
100 |
2 |
| 3 |
1 |
80 |
3 |
У каждого клиента свой rn = 1 — самый дорогой заказ именно у этого клиента.
Top-N в каждой группе
Это самый частый use case. «Топ-3 заказа каждого клиента»:
WITH ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS rn
FROM orders
)
SELECT * FROM ranked WHERE rn <= 3;
Без оконных функций такое решалось бы через LATERAL JOIN или хитрый коррелированный подзапрос — длинно, медленно. С ROW_NUMBER — пять строк.
Удаление дубликатов (dedup)
Сценарий: в таблице users несколько строк с одним email (наследие старых импортов), хочется оставить только самую свежую:
WITH ranked AS (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at DESC) AS rn
FROM users
)
DELETE FROM users
WHERE id IN (SELECT id FROM ranked WHERE rn > 1);
PARTITION BY email — все строки с одним email вместе.
ORDER BY created_at DESC — самая свежая первая.
rn = 1 — самая свежая. rn > 1 — дубли. Удаляем дубли.
Это канонический паттерн для очистки данных после миграций.
ROW_NUMBER в WHERE — оборачивай в подзапрос
Важно: ROW_NUMBER нельзя использовать прямо в WHERE того же запроса:
SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS rn
FROM users
WHERE rn = 1;
Причина — оконные функции вычисляются после WHERE, FROM, GROUP BY. Чтобы фильтровать по rn — оборачивай в подзапрос или CTE:
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS rn FROM users
) t
WHERE rn = 1;
То же правило для всех оконных функций.
ROW_NUMBER vs RANK vs DENSE_RANK
Все три нумеруют, но по-разному:
ROW_NUMBER: 1, 2, 3, 4, 5 — даже если у строк одинаковый ORDER BY ключ.
RANK: 1, 2, 2, 4, 5 — при ничьей одинаковый ранг и пропуск.
DENSE_RANK: 1, 2, 2, 3, 4 — при ничьей одинаковый ранг без пропуска.
ROW_NUMBER нужен, когда точно один номер на строку. RANK/DENSE_RANK — когда обработка ничьих важна. Подробнее в отдельной статье.
Частые ошибки новичков
1. ROW_NUMBER в WHERE без обёртки. Уже разобрали — оборачивай в подзапрос или CTE.
2. ROW_NUMBER без ORDER BY. Технически работает (Postgres не упадёт), но порядок строк недетерминированный. Каждый запуск может дать разные rn. Всегда указывай ORDER BY.
3. Неуникальные ORDER BY. Если у двух строк одинаковый created_at, и ты сортируешь только по created_at — какой строке достанется rn = 1, не определено. Добавь tie-breaker: ORDER BY created_at DESC, id.
4. Забыли PARTITION BY, нумеруют глобально. Хочешь топ-3 на клиента, написал ROW_NUMBER() OVER (ORDER BY created_at DESC) без PARTITION BY — пронумеровал ВСЕ заказы целиком, получил «топ-3 за всю историю», не «топ-3 на клиента».
5. ROW_NUMBER ≠ постоянный id. rn пересчитывается каждый раз. Не используй для генерации стабильных идентификаторов в БД.
6. Перфоманс на больших партициях для топ-1. Если в партиции миллионы строк, а тебе нужен только rn=1 — DISTINCT ON (key) или LIMIT 1 через подзапрос будут быстрее, чем нумеровать всю партицию и потом фильтровать.
Мини-резюме
ROW_NUMBER() OVER (ORDER BY ...) — уникальный номер для каждой строки.
PARTITION BY — нумерация заново внутри каждой группы. Основа всех «top-N per group» паттернов.
WHERE rn = N — только в обёртке (подзапрос или CTE).
- Всегда указывай
ORDER BY внутри OVER. Если ничьи возможны — добавь tie-breaker (например, id).
- Канонический паттерн dedup:
PARTITION BY ключ ORDER BY свежесть DESC, оставить rn = 1.
- Если ничьи нужно обрабатывать как «равный ранг» — это уже
RANK/DENSE_RANK, не ROW_NUMBER.
ROW_NUMBER— это оконная функция, которая нумерует строки по порядку. Первая получает 1, вторая — 2, и так далее. Никаких ничьих, никаких пропусков — у каждой строки свой уникальный номер.Это самая простая из оконных функций, и обычно с неё начинают изучение «окон». Поэтому если ты когда-нибудь слышал, что «оконные функции — это сложно», начни с ROW_NUMBER. На нём всё становится понятно.
Зачем нужен ROW_NUMBER
Главные сценарии:
До оконных функций такие задачи решались через коррелированные подзапросы или хитрые self-join — длинно и медленно. С
ROW_NUMBERэто пара строк.Базовый синтаксис
ROW_NUMBER() OVER (ORDER BY column [DESC])ROW_NUMBER()— функция (без аргументов).OVER (...)— обязательно, говорит «это оконная функция».ORDER BY— в каком порядке нумеровать.SELECT name, amount, ROW_NUMBER() OVER (ORDER BY amount DESC) AS rn FROM orders;ROW_NUMBER() OVER (ORDER BY amount DESC)— пронумеруй строки по убыванию суммы. Самый дорогой получит rn = 1, следующий — rn = 2.Пример с таблицей
orders:Запрос с глобальной нумерацией по убыванию суммы:
SELECT id, customer_id, amount, ROW_NUMBER() OVER (ORDER BY amount DESC) AS rn FROM orders;Результат:
PARTITION BY — нумерация внутри групп
Теперь самое важное.
ROW_NUMBERумеет нумеровать внутри групп:SELECT id, customer_id, amount, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS rn FROM orders;PARTITION BY customer_id— раздели строки по клиенту.ORDER BY amount DESC— внутри каждого клиента отсортируй по убыванию.ROW_NUMBER— пронумеруй с 1 заново в каждой группе.Результат:
У каждого клиента свой rn = 1 — самый дорогой заказ именно у этого клиента.
Top-N в каждой группе
Это самый частый use case. «Топ-3 заказа каждого клиента»:
WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS rn FROM orders ) SELECT * FROM ranked WHERE rn <= 3;Без оконных функций такое решалось бы через
LATERAL JOINили хитрый коррелированный подзапрос — длинно, медленно. СROW_NUMBER— пять строк.Удаление дубликатов (dedup)
Сценарий: в таблице
usersнесколько строк с одним email (наследие старых импортов), хочется оставить только самую свежую:WITH ranked AS ( SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at DESC) AS rn FROM users ) DELETE FROM users WHERE id IN (SELECT id FROM ranked WHERE rn > 1);PARTITION BY email— все строки с одним email вместе.ORDER BY created_at DESC— самая свежая первая.rn = 1— самая свежая.rn > 1— дубли. Удаляем дубли.Это канонический паттерн для очистки данных после миграций.
ROW_NUMBER в WHERE — оборачивай в подзапрос
Важно:
ROW_NUMBERнельзя использовать прямо вWHEREтого же запроса:-- НЕ РАБОТАЕТ SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS rn FROM users WHERE rn = 1; -- ERROR: column "rn" does not existПричина — оконные функции вычисляются после
WHERE,FROM,GROUP BY. Чтобы фильтровать по rn — оборачивай в подзапрос или CTE:SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS rn FROM users ) t WHERE rn = 1;То же правило для всех оконных функций.
ROW_NUMBER vs RANK vs DENSE_RANK
Все три нумеруют, но по-разному:
ROW_NUMBER: 1, 2, 3, 4, 5 — даже если у строк одинаковый ORDER BY ключ.RANK: 1, 2, 2, 4, 5 — при ничьей одинаковый ранг и пропуск.DENSE_RANK: 1, 2, 2, 3, 4 — при ничьей одинаковый ранг без пропуска.ROW_NUMBERнужен, когда точно один номер на строку.RANK/DENSE_RANK— когда обработка ничьих важна. Подробнее в отдельной статье.Частые ошибки новичков
1. ROW_NUMBER в WHERE без обёртки. Уже разобрали — оборачивай в подзапрос или CTE.
2. ROW_NUMBER без ORDER BY. Технически работает (Postgres не упадёт), но порядок строк недетерминированный. Каждый запуск может дать разные
rn. Всегда указывайORDER BY.3. Неуникальные ORDER BY. Если у двух строк одинаковый
created_at, и ты сортируешь только поcreated_at— какой строке достанетсяrn = 1, не определено. Добавь tie-breaker:ORDER BY created_at DESC, id.4. Забыли PARTITION BY, нумеруют глобально. Хочешь топ-3 на клиента, написал
ROW_NUMBER() OVER (ORDER BY created_at DESC)безPARTITION BY— пронумеровал ВСЕ заказы целиком, получил «топ-3 за всю историю», не «топ-3 на клиента».5. ROW_NUMBER ≠ постоянный id.
rnпересчитывается каждый раз. Не используй для генерации стабильных идентификаторов в БД.6. Перфоманс на больших партициях для топ-1. Если в партиции миллионы строк, а тебе нужен только rn=1 —
DISTINCT ON (key)илиLIMIT 1через подзапрос будут быстрее, чем нумеровать всю партицию и потом фильтровать.Мини-резюме
ROW_NUMBER() OVER (ORDER BY ...)— уникальный номер для каждой строки.PARTITION BY— нумерация заново внутри каждой группы. Основа всех «top-N per group» паттернов.WHERE rn = N— только в обёртке (подзапрос или CTE).ORDER BYвнутриOVER. Если ничьи возможны — добавь tie-breaker (например,id).PARTITION BY ключ ORDER BY свежесть DESC, оставитьrn = 1.RANK/DENSE_RANK, неROW_NUMBER.