SQLROW_NUMBERwindowtutorial

Что такое ROW_NUMBER в SQL?

ROW_NUMBER — это «дай каждой строке свой номер по порядку». Простыми словами: первая оконная функция, которую стоит изучить. Нумерация по убыванию, нумерация внутри групп через PARTITION BY, классические задачи top-N в каждой группе и удаление дубликатов. С таблицами и частыми ошибками.

12 мин чтенияСправочникSQL · ROW_NUMBER · window · tutorial

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;

Здесь логика такая:

  1. PARTITION BY customer_id делит заказы по клиентам.
  2. ORDER BY amount DESC сортирует заказы внутри каждого клиента от дорогих к дешёвым.
  3. 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;

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

  1. В CTE ranked_orders мы добавляем к каждому заказу номер внутри клиента.
  2. Самый дорогой заказ клиента получает row_num = 1.
  3. Второй по сумме получает row_num = 2.
  4. Третий получает row_num = 3.
  5. Внешний запрос оставляет только строки с 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.

id email created_at
1 anna@example.com 2024-03-01
2 bob@example.com 2024-03-02
3 anna@example.com 2024-03-10
4 anna@example.com 2024-03-15
5 bob@example.com 2024-03-20

Нужно оставить самую свежую строку для каждого email, а старые дубли удалить.

Сначала посмотрим, как пронумеровать строки:

SELECT
  id,
  email,
  created_at,
  ROW_NUMBER() OVER (
    PARTITION BY email
    ORDER BY created_at DESC, id DESC
  ) AS row_num
FROM users;

Результат:

id email created_at row_num
4 anna@example.com 2024-03-15 1
3 anna@example.com 2024-03-10 2
1 anna@example.com 2024-03-01 3
5 bob@example.com 2024-03-20 1
2 bob@example.com 2024-03-02 2

Теперь логика понятна:

  • 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.

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

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

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