Оконные функции SQL: ROW_NUMBER, RANK, LAG, LEAD с примерами — new-lvl.pro
new-lvl.pro · Статьи · SQL
Статья // 15 мин чтения

Оконные функции
SQL: полный разбор
с примерами

ROW_NUMBER, RANK, LAG, LEAD, SUM OVER — каждая функция с объяснением, SQL-запросом и результатом. Задачи уровня собеседований в Яндексе и Авито.

Что такое оконные функции и зачем они нужны

Оконная функция выполняет вычисление по группе строк, связанных с текущей строкой, но при этом не схлопывает результат в одну строку (как GROUP BY). Каждая строка остаётся в результате — к ней просто добавляется новый столбец.

Простой пример: у вас таблица заказов. Вы хотите к каждому заказу добавить сумму всех заказов этого же клиента. С GROUP BY вы получите одну строку на клиента. С оконной функцией — все строки останутся, но в каждой появится итог.

Общий синтаксис

Структура оконной функции
ФУНКЦИЯ(аргумент) OVER (
  PARTITION BY столбец_группировки
  ORDER BY столбец_сортировки
  ROWS BETWEEN ... AND ... ← фрейм (опционально)
)
PARTITION BY
Разбивка на группы
Аналог GROUP BY, но без схлопывания строк. Определяет, в рамках какой группы считать. Если опустить — окно = вся таблица.
ORDER BY
Порядок внутри окна
Определяет порядок строк в окне. Обязателен для ранжирующих функций (ROW_NUMBER, RANK) и для LAG/LEAD.
// PARTITION BY ≠ GROUP BY
GROUP BY схлопывает строки — одна строка на группу. PARTITION BY сохраняет все строки — каждая получает результат вычисления по своей группе. Это самый частый вопрос на собесе: «В чём разница?»

ROW_NUMBER, RANK, DENSE_RANK

Три функции, которые присваивают строкам номер внутри окна. Разница — в обработке одинаковых значений (дубликатов).

ROW_NUMBER()
Уникальный номер
Всегда уникальный: 1, 2, 3, 4... Даже при одинаковых значениях — номера не повторяются (порядок среди дубликатов не определён).
RANK()
С пропусками
Одинаковые значения → одинаковый ранг. После дубликатов — пропуск. Пример: 1, 2, 2, 4 (пропущен 3).
DENSE_RANK()
Без пропусков
Одинаковые значения → одинаковый ранг. Но без пропуска. Пример: 1, 2, 2, 3 (без пропуска).

Пример: топ-3 заказа каждого клиента

Классическая задача: для каждого клиента найти 3 самых крупных заказа. Без оконных функций — боль. С ROW_NUMBER — три строчки.

Топ-N по группам через ROW_NUMBER PostgreSQL
WITH ranked AS (
  SELECT
    user_id,
    order_id,
    amount,
    order_date,
    ROW_NUMBER() OVER (
      PARTITION BY user_id
      ORDER BY amount DESC
    ) AS rn
  FROM orders
)
SELECT *
FROM ranked
WHERE rn <= 3;
// Результат
user_idorder_idamountorder_datern
101A-558 2002025-03-121
101A-415 4002025-02-082
101A-633 1002025-04-013
202B-1212 0002025-01-201
202B-347 6002025-03-052
202B-284 9002025-02-183
// Паттерн «Топ-N по группам»
Этот паттерн — один из самых частых на собесах. CTE + ROW_NUMBER + WHERE rn <= N. Работает для: «последний заказ каждого клиента» (rn = 1, ORDER BY date DESC), «самый дорогой товар в каждой категории», «первое действие пользователя».

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

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

RANK vs DENSE_RANK — разница PostgreSQL
SELECT
  seller_id,
  revenue,
  RANK()       OVER (ORDER BY revenue DESC) AS rank_val,
  DENSE_RANK() OVER (ORDER BY revenue DESC) AS dense_rank_val,
  ROW_NUMBER() OVER (ORDER BY revenue DESC) AS row_num
FROM sellers;
// Результат
seller_idrevenuerank_valdense_rank_valrow_num
S-150 000111
S-242 000222
S-342 000223
S-438 000434

Видно: RANK пропустил 3 (после двух вторых мест — сразу 4). DENSE_RANK — не пропустил (после двух вторых — сразу 3). ROW_NUMBER дал уникальные номера обоим.

LAG и LEAD: доступ к предыдущей и следующей строке

LAG() — значение из предыдущей строки. LEAD() — из следующей. Незаменимы для расчёта изменений: «на сколько выросла выручка по сравнению с прошлым месяцем?», «сколько времени между двумя действиями пользователя?».

Пример: месячный рост выручки

Month-over-Month рост через LAG PostgreSQL
WITH monthly AS (
  SELECT DATE_TRUNC('month', order_date) AS month,
    SUM(amount) AS revenue
  FROM orders
  GROUP BY 1
)
SELECT
  month,
  revenue,
  LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
  ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
    / LAG(revenue) OVER (ORDER BY month), 1)
    AS mom_growth_pct
FROM monthly;
// Результат
monthrevenueprev_revenuemom_growth_pct
2025-011 200 000NULLNULL
2025-021 350 0001 200 000+12.5%
2025-031 280 0001 350 000−5.2%
2025-041 510 0001 280 000+18.0%

Первая строка — NULL, потому что у января нет «предыдущего» месяца. Это нормальное поведение LAG. Можно задать значение по умолчанию: LAG(revenue, 1, 0) — третий аргумент.

Пример: время между действиями пользователя (сессии)

Задача sessionization — одна из классических на собесах. Определяем сессии: если между двумя действиями прошло больше 30 минут — это новая сессия.

Sessionization через LAG PostgreSQL
WITH with_prev AS (
  SELECT
    user_id,
    event_time,
    LAG(event_time) OVER (
      PARTITION BY user_id
      ORDER BY event_time
    ) AS prev_time
  FROM events
),
with_flag AS (
  SELECT
    *,
    CASE WHEN prev_time IS NULL OR event_time - prev_time > INTERVAL '30 minutes' THEN 1 ELSE 0 END AS new_session
  FROM with_prev
)
SELECT
  user_id,
  event_time,
  SUM(new_session) OVER (
    PARTITION BY user_id
    ORDER BY event_time
  ) AS session_id
FROM with_flag;
// Разбор логики
Шаг 1: LAG достаёт время предыдущего события. Шаг 2: если разница > 30 мин (или нет предыдущего) — ставим флаг 1 (новая сессия). Шаг 3: SUM OVER с ORDER BY накопительно считает флаги — это и есть session_id. Красивая комбинация LAG + SUM OVER.

SUM, AVG, COUNT OVER: агрегатные оконные функции

Любую агрегатную функцию (SUM, AVG, COUNT, MAX, MIN) можно использовать как оконную — просто добавив OVER(). Это даёт мощные возможности: running total, скользящее среднее, доля от общего.

Running total — накопительная сумма

Накопительная выручка по дням PostgreSQL
SELECT
  order_date,
  SUM(amount) AS daily_revenue,
  SUM(SUM(amount)) OVER (
    ORDER BY order_date
  ) AS running_total
FROM orders
GROUP BY order_date
ORDER BY order_date;

SUM(SUM(amount)) — выглядит странно, но работает: внутренний SUM группирует по дню (GROUP BY), внешний SUM OVER — накапливает по окну. Это стандартный паттерн running total.

Доля от общего — процент каждой категории

Доля выручки по категориям PostgreSQL
SELECT
  category,
  SUM(amount) AS revenue,
  ROUND(100.0 * SUM(amount)
    / SUM(SUM(amount)) OVER (), 1)
    AS pct_of_total
FROM orders
GROUP BY category
ORDER BY revenue DESC;

OVER() без PARTITION BY и без ORDER BY — окно = вся таблица. SUM OVER() даёт общую сумму, к которой мы делим выручку каждой категории. Элегантно и часто встречается на собесе.

Скользящее среднее (Moving Average)

7-дневное скользящее среднее PostgreSQL
SELECT
  day,
  daily_users,
  ROUND(AVG(daily_users) OVER (
    ORDER BY day
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ), 0) AS ma_7d
FROM daily_stats;

ROWS BETWEEN 6 PRECEDING AND CURRENT ROW — это фрейм: берём текущую строку и 6 предыдущих (итого 7 дней). AVG по этому окну — скользящее среднее. Снимает дневной шум и показывает тренд.

Фреймы окна: ROWS и RANGE

Фрейм определяет, какие строки внутри окна участвуют в вычислении. По умолчанию (если есть ORDER BY) — от начала окна до текущей строки. Но можно задать явно.

ФреймЧто делаетПример использования
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWОт начала до текущей строки (по умолчанию с ORDER BY)Running total, накопительный LTV
ROWS BETWEEN N PRECEDING AND CURRENT ROWПоследние N+1 строкСкользящее среднее за 7 дней
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWINGВсё окно целикомПроцент от общей суммы
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWINGПредыдущая + текущая + следующая строкаСглаживание выбросов
// Подводный камень на собесе
Если в оконной функции есть ORDER BY, но нет явного фрейма — по умолчанию используется RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Для SUM это значит running total, а не сумму всего окна. Если хотите сумму по всему окну — либо уберите ORDER BY, либо задайте фрейм явно.

Задачи с собеседований: оконные функции

Задача 1: Разбейте пользователей по децилям по сумме покупок

Эта задача встречается на собесах в Яндексе и Авито. NTILE(10) разбивает строки на 10 равных групп.

Децили пользователей по выручке PostgreSQL
WITH user_revenue AS (
  SELECT
    user_id,
    SUM(amount) AS total_revenue
  FROM orders
  GROUP BY user_id
)
SELECT NTILE(10) OVER (ORDER BY total_revenue DESC) AS decile,
  COUNT(*) AS users,
  SUM(total_revenue) AS revenue,
  ROUND(100.0 * SUM(total_revenue)
    / SUM(SUM(total_revenue)) OVER (), 1) AS pct_revenue
FROM (
  SELECT
    user_id,
    total_revenue,
    NTILE(10) OVER (ORDER BY total_revenue DESC) AS decile
  FROM user_revenue
) t
GROUP BY decile
ORDER BY decile;

Типичный результат: топ-10% пользователей приносят 60–80% выручки. Это правило Парето в действии — и его часто спрашивают на кейсовых интервью.

Задача 2: Найдите клиентов, у которых сумма следующего заказа меньше предыдущего

Сравнение последовательных заказов через LAG PostgreSQL
SELECT
  user_id,
  order_date,
  amount,
  LAG(amount) OVER (
    PARTITION BY user_id ORDER BY order_date
  ) AS prev_amount
FROM orders
HAVING amount < LAG(amount) OVER (
  PARTITION BY user_id ORDER BY order_date
);
-- Так не работает! Оконные функции нельзя в WHERE/HAVING -- Правильный способ: WITH with_prev AS (
  SELECT
    user_id, order_date, amount,
    LAG(amount) OVER (
      PARTITION BY user_id ORDER BY order_date
    ) AS prev_amount
  FROM orders
)
SELECT *
FROM with_prev
WHERE amount < prev_amount;
// Ключевое правило
Оконные функции нельзя использовать в WHERE и HAVING — только в SELECT и ORDER BY. Если нужно фильтровать по результату оконной функции — оборачивайте в CTE или подзапрос. Это одна из самых частых ошибок на собесе.

Задача 3: Когортный Retention через оконную функцию

Комбинация оконных функций с когортным анализом — верх мастерства на собесе. FIRST_VALUE берёт размер когорты, чтобы посчитать процент.

Retention через FIRST_VALUE PostgreSQL
WITH cohort_activity AS (
  SELECT DATE_TRUNC('month', u.registration_date) AS cohort,
    DATE_PART('month',
      AGE(DATE_TRUNC('month', a.activity_date),
          DATE_TRUNC('month', u.registration_date))
    )::INT AS period,
    COUNT(DISTINCT u.user_id) AS active_users
  FROM users u
  JOIN user_activity a ON u.user_id = a.user_id
  GROUP BY 1, 2
)
SELECT
  cohort,
  period,
  active_users,
  ROUND(100.0 * active_users
    / FIRST_VALUE(active_users) OVER (
        PARTITION BY cohort ORDER BY period
      ), 1
  ) AS retention_pct
FROM cohort_activity
ORDER BY cohort, period;

Шпаргалка: какую функцию когда использовать

ЗадачаФункцияПример
Топ-N по группамROW_NUMBERПоследний заказ каждого клиента
Рейтинг с дубликатамиRANK / DENSE_RANKРейтинг продавцов по выручке
Сравнение с предыдущимLAGMoM рост выручки
Время до следующегоLEADКогда будет следующий заказ
Накопительный итогSUM OVER (ORDER BY)Running total, кумулятивный LTV
Доля от общегоSUM OVER ()% выручки по категориям
Скользящее среднееAVG OVER (ROWS N PRECEDING)MA-7 по DAU
Разбивка на группыNTILE(N)Децили пользователей по LTV
Первое/последнее в группеFIRST_VALUE / LAST_VALUEБазовый размер когорты для retention
Определение сессийLAG + SUM OVERSessionization по 30-мин gap

Вопросы про оконные функции на собесе

🎤 Типичные вопросы
  • В чём разница между PARTITION BY и GROUP BY?
  • Чем отличаются ROW_NUMBER, RANK и DENSE_RANK? Приведите пример, когда результат будет разным.
  • Для каждого пользователя найдите его последний заказ. Напишите SQL.
  • Посчитайте month-over-month рост DAU. Напишите запрос с LAG.
  • Разбейте пользователей по децилям по сумме покупок. Какой процент выручки даёт топ-10%?
  • Определите сессии пользователей с gap > 30 минут. Напишите запрос.
  • Постройте когортную таблицу retention. Используйте оконные функции для расчёта процента.
  • Можно ли использовать оконную функцию в WHERE? Почему?
  • Ваш запрос работает 10 минут на 100 млн строк. Как оптимизировать?

Связанные материалы

Главное про оконные функции

Оконные функции — это то, что отделяет junior от middle в SQL. Они позволяют решать задачи, которые без них требуют громоздких подзапросов или вообще невозможны: ранжирование по группам, сравнение с предыдущим значением, скользящие средние, sessionization.

На собеседовании в BigTech вас точно спросят задачу на оконные функции. Самые частые паттерны: «топ-N по группам» (ROW_NUMBER), «рост метрики» (LAG), «доля от общего» (SUM OVER()). Если знаете эти три — вы закрываете 80% задач.

Следующий шаг: откройте любой SQL-тренажёр и решите 10 задач на оконные функции. Теория без практики забывается за неделю, а после 10 задач паттерны входят в мышечную память.

АТ
Андрей Тарасенко
// Продуктовый аналитик · Авито · Ментор

Оконные функции в Авито — ежедневный инструмент. Когортный retention, MoM-рост метрик, сессии — всё это оконки. Написал статью так, чтобы после неё задачи на собесе не пугали.

Написать в Telegram