Оконные функции в SQL: примеры и синтаксис

SQL Автор: Среда и версия: SQL:2003; примеры совместимы с PostgreSQL, версия среды не зафиксирована
содержание

Оконная функция считает по группе строк, но строки не схлопывает. GROUP BY из шести заказов делает три итога и теряет исходные записи. Окно оставляет все шесть, а рядом дописывает расчёт: сумму по клиенту, номер в рейтинге, значение предыдущей строки. Отсюда и название — функция смотрит на соседей через «окно», не выходя из своей строки.

SQL / 01

Оконная сумма дописывается к каждой строке

Оконная сумма дописывается к каждой строке01 / строка заказа 02 / окно 03 / SUM(amount) 10 · user 1 · 900 PARTITION: user 1 4100 11 · user 1 · 2400 PARTITION: user 1 4100 12 · user 2 · 1200 PARTITION: user 2 2400 13 · user 2 · 1200 PARTITION: user 2 2400 14 · user 3 · 500 PARTITION: user 3 500 15 · user 1 · 800 PARTITION: user 1 410001 / строка заказа02 / окно03 / SUM(amount)10 · user 1 · 900PARTITION: user 1410011 · user 1 · 2400PARTITION: user 1410012 · user 2 · 1200PARTITION: user 2240013 · user 2 · 1200PARTITION: user 2240014 · user 3 · 500PARTITION: user 350015 · user 1 · 800PARTITION: user 14100
PARTITION BY задаёт группы для расчёта; оконная сумма дописывается к каждой строке, а не заменяет её.

Весь разбор — на одной таблице orders:

iduser_iddayamount
1012026-06-01900
1112026-06-032400
1222026-06-031200
1322026-06-071200
1432026-06-09500
1512026-06-12800

Пользователи: 1 — Аня, 2 — Борис, 3 — Вера.

Чем оконная функция отличается от GROUP BY

GROUP BY возвращает по одной строке на группу. Оконная функция возвращает столько строк, сколько было на входе.

SELECT user_id, sum(amount) AS total
FROM orders
GROUP BY user_id;
user_idtotal
14100
22400
3500

Три строки, отдельных заказов больше нет. Теперь то же самое окном:

SELECT id, user_id, amount,
       sum(amount) OVER (PARTITION BY user_id) AS user_total
FROM orders;
iduser_idamountuser_total
1019004100
11124004100
1518004100
12212002400
13212002400
143500500

Шесть заказов на месте, к каждому приписан итог его клиента. Это и есть типовой повод взять окно: показать строку и её долю в целом одновременно. Через GROUP BY так не выйдет — пришлось бы считать итоги отдельным запросом и присоединять обратно.

Из чего состоит OVER

OVER — обязательная часть синтаксиса, она и превращает обычную функцию в оконную. Внутри — два необязательных элемента:

функция(...) OVER (PARTITION BY колонка ORDER BY колонка)
  • PARTITION BY режет таблицу на независимые группы. Расчёт идёт внутри каждой отдельно, между группами данные не перетекают. Без PARTITION BY вся выборка — одно окно.
  • ORDER BY задаёт порядок строк внутри группы. Он нужен всему, что зависит от последовательности: нумерации, рангу, соседним строкам, накопительным итогам.

OVER () с пустыми скобками — тоже законная запись: одно окно на всю выборку без сортировки.

ROW_NUMBER, RANK и DENSE_RANK: в чём разница

Все три нумеруют строки, различаются обработкой одинаковых значений. У Бориса два заказа по 1200 — на них разница и видна.

SELECT id, amount,
       row_number() OVER (ORDER BY amount DESC) AS rn,
       rank()       OVER (ORDER BY amount DESC) AS rnk,
       dense_rank() OVER (ORDER BY amount DESC) AS dense
FROM orders;
idamountrnrnkdense
112400111
121200222
131200322
10900443
15800554
14500665
  • row_number() нумерует подряд и никогда не повторяется — двум одинаковым суммам всё равно достанутся 2 и 3.
  • rank() даёт равным строкам общий номер, а потом прыгает через пропуск: после двух вторых мест идёт четвёртое.
  • dense_rank() тоже уравнивает, но пропусков не делает: 1, 2, 2, 3.

Разница между этими нумерациями нужна не только для призовых мест: на ней стоит классический приём поиска непрерывных серий — gaps and islands, где разность двух нумераций и оказывается признаком одной серии.

Тонкость, о которой спрашивают следом: какая из двух строк по 1200 получит номер 2, а какая 3, база решает сама — при равенстве ключа сортировки порядок не определён. Для воспроизводимого row_number() добавь второй ключ в его окно: ORDER BY amount DESC, id. У rank() и dense_rank() оставь сортировку только по сумме, если одинаковые суммы должны сохранять общий ранг.

LAG и LEAD: сравнить строку с соседней

lag() берёт значение из предыдущей строки окна, lead() — из следующей. Это готовый ответ на вопросы вида «насколько заказ отличается от прошлого».

SELECT id, user_id, day, amount,
       lag(amount) OVER (PARTITION BY user_id ORDER BY day) AS prev_amount
FROM orders;
iduser_iddayamountprev_amount
1012026-06-01900NULL
1112026-06-032400900
1512026-06-128002400
1222026-06-031200NULL
1322026-06-0712001200
1432026-06-09500NULL

У первого заказа каждого клиента предыдущего нет, поэтому NULL. Считать разницу напрямую (amount - lag(amount) OVER (...)) тоже даст NULL — подставь запасное значение третьим аргументом: lag(amount, 1, 0).

Как посчитать накопительный итог

Сумма с ORDER BY внутри окна перестаёт быть общим итогом и становится накопительной: каждая строка видит себя и всё, что было раньше.

SELECT day, amount,
       sum(amount) OVER (ORDER BY day) AS running_total
FROM orders;
dayamountrunning_total
2026-06-01900900
2026-06-0324004500
2026-06-0312004500
2026-06-0712005700
2026-06-095006200
2026-06-128007000

Присмотрись к двум строкам за 3 июня: у обеих итог 4500, хотя суммы разные. Это не ошибка, а поведение рамки по умолчанию — про неё дальше.

Рамка окна: почему ROWS и RANGE дают разный ответ

Когда в окне есть ORDER BY, база молча дописывает рамку RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. RANGE работает не по строкам, а по значениям ключа сортировки: все строки с одинаковым day считаются одной позицией и получают общий итог.

Нужен воспроизводимый пошаговый итог — задай рамку явно в строках и дополни сортировку уникальным id:

SELECT day, amount,
       sum(amount) OVER (ORDER BY day, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM orders
ORDER BY day, id;
dayamountrunning_total
2026-06-01900900
2026-06-0324003300
2026-06-0312004500
2026-06-0712005700
2026-06-095006200
2026-06-128007000

Итоговая сумма сошлась, промежуточные — нет. ROWS считает по строкам, а id определяет, какая из двух записей за 3 июня попадёт в накопительный итог первой. Одного ROWS при неуникальном ключе недостаточно: промежуточные суммы могут меняться вместе с порядком равных строк. Для дневного итога сначала сгруппируй данные по дню, а для пошагового задай и ROWS, и однозначную сортировку.

Топ-1 в каждой группе

Самый частый практический сценарий: последний заказ каждого клиента, бестселлер каждой категории, первое событие каждой сессии. Схема одна — пронумеровать внутри группы, затем отобрать номер 1.

SELECT id, user_id, day, amount
FROM (
  SELECT *, row_number() OVER (PARTITION BY user_id ORDER BY day DESC) AS rn
  FROM orders
) t
WHERE rn = 1;
iduser_iddayamount
1512026-06-12800
1322026-06-071200
1432026-06-09500

Подзапрос здесь не для красоты, он обязателен — и вот почему.

Почему оконную функцию нельзя написать в WHERE

Запрос выполняется в фиксированном порядке: FROM → WHERE → GROUP BY → HAVING → оконные функции → SELECT → ORDER BY → LIMIT. Окна считаются после WHERE, поэтому в момент фильтрации их результата ещё не существует.

-- не выполнится
SELECT * FROM orders
WHERE row_number() OVER (PARTITION BY user_id ORDER BY day DESC) = 1;

PostgreSQL ответит window functions are not allowed in WHERE. Обходов два: подзапрос или CTE (WITH ranked AS (...) SELECT * FROM ranked WHERE rn = 1) — работают везде. В ClickHouse, Snowflake и BigQuery есть сокращение QUALIFY, которое фильтрует по окну напрямую, но в PostgreSQL и MySQL его нет.

Из того же порядка выполнения следует ещё одно: окно видит только строки, пережившие WHERE. Отфильтровал отменённые заказы — накопительный итог посчитается по оплаченным, и это обычно именно то, что нужно.

Частые вопросы

В каких СУБД работают оконные функции

Это стандарт SQL:2003. PostgreSQL поддерживает их с версии 8.4, SQL Server — с 2005, MySQL — только с 8.0, SQLite — с 3.25. На MySQL 5.7 запрос с OVER не выполнится, там всё ещё пишут через переменные или самосоединения.

Можно ли использовать окно вместе с GROUP BY

Да, и это рабочий приём: окно считается после группировки, то есть поверх уже агрегированных строк. Дневная выручка и накопленный итог в одном запросе выглядят так:

SELECT day, sum(amount) AS day_revenue,
       sum(sum(amount)) OVER (ORDER BY day) AS running_total
FROM orders
GROUP BY day
ORDER BY day;

Двойной sum(sum(...)) пугает, но читается прямо: внутренний схлопывает день, внешний идёт окном по дням.

Чем окно лучше подзапроса

Тот же результат часто достигается коррелированным подзапросом. Если план оставляет его как зависимый SubPlan, расчёт повторяется для внешних строк. Окно позволяет считать по общему отсортированному набору и часто получается короче. Выигрыш на больших таблицах проверяют по плану, а не по самому наличию подзапроса.

Что почитать дальше

Окна почти всегда стоят рядом с соединениями: сначала собираешь данные из нескольких таблиц, потом считаешь по ним рейтинги. Если соединения пока не уложились — начни с разбора видов JOIN в SQL, а типовые ошибки с NULL собраны в статье про LEFT JOIN.

Для практики открой задачу на лидера каждого отдела: там ROW_NUMBER() должен выбрать ровно одного сотрудника с детерминированным разрешением ничьих.

Источники