У GROUP BY есть ограничение, в которое упирается каждый: он схлопывает строки. Посчитали заказы по клиентам — и видите итоги, но потеряли сами заказы. А задачи сплошь и рядом требуют и того и другого: «покажи каждый заказ и рядом — какой он по счёту у этого клиента», «выведи последний заказ каждого клиента». Для этого придуманы оконные функции — агрегаты, которые считают по группе, но строки не схлопывают.
OVER: агрегат без схлопывания
Сравните. Обычный агрегат:
SELECT customer_id, COUNT(*) FROM orders GROUP BY customer_id;
-- 2 строки: клиент → сколько заказов
Оконный вариант:
SELECT id, customer_id, amount,
COUNT(*) OVER (PARTITION BY customer_id) AS orders_of_customer
FROM orders;
-- все заказы на месте, у каждого — приписка «сколько всего у этого клиента»
Ключевое слово OVER превращает функцию в оконную: она смотрит на «окно» — набор строк вокруг текущей — и приписывает результат к строке, не убирая её. PARTITION BY customer_id задаёт границы окна: считать в пределах одного клиента. Это тот же смысл, что у GROUP BY, только без потери строк.
ROW_NUMBER: нумерация внутри группы
Самая используемая оконная функция — ROW_NUMBER(): она нумерует строки внутри окна в заданном порядке.
SELECT id, customer_id, created_at,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
FROM orders;
ORDER BY created_at DESC внутри OVER задаёт порядок нумерации: свежайший заказ клиента получает номер 1, следующий — 2, и так далее — у каждого клиента своя нумерация.
Отсюда решение классической задачи «последняя запись по каждой группе», которую без окон решают мучительно:
SELECT * FROM (
SELECT o.*,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
FROM orders o
) t
WHERE rn = 1;
Пронумеровали заказы каждого клиента от свежих к старым — и оставили только первые. Внешняя обёртка нужна потому, что фильтровать по оконной функции прямо в WHERE нельзя: окно вычисляется после него.
Рядом живут RANK() и DENSE_RANK() — ранги с учётом «дележа мест»: при равных значениях ROW_NUMBER присвоит номера произвольно (1, 2), RANK даст обоим первое место и перепрыгнет (1, 1, 3), DENSE_RANK — без пропуска (1, 1, 2).
Нарастающий итог и сосед по строке
Ещё два приёма из повседневности:
- Нарастающий итог —
SUMс ORDER BY внутри окна:
SELECT id, created_at, amount,
SUM(amount) OVER (ORDER BY created_at) AS running_total
FROM orders;
Каждая строка получает сумму всех заказов по эту дату включительно — график накопленной выручки одной строчкой.
- Сравнение с предыдущей строкой —
LAG(и симметричныйLEADдля следующей):
SELECT id, created_at,
created_at - LAG(created_at) OVER (PARTITION BY customer_id ORDER BY created_at) AS since_prev
FROM orders;
LAG подтягивает значение из предыдущей строки окна — и вы видите, сколько прошло между заказами клиента. Поиск аномалий во временных рядах (дубли с разницей в секунду, подозрительные паузы) делается именно так.
Где это применяется
Оконные функции — черта, отделяющая «пишу запросы» от «свободно разговариваю с базой». Задачи, которые без них требуют выгрузки в Excel или подзапросов-этажерок, решаются строчкой: последний статус каждой заявки, топ-3 товара в каждой категории, время между событиями пользователя, дубликаты «оставить первый, найти остальные» (rn > 1). В проверках данных это рабочая лошадка: ROW_NUMBER … WHERE rn = 1 — эталонный способ взять актуальную запись из таблицы-истории.
Где спотыкаются начинающие:
- Путают ORDER BY окна и ORDER BY запроса. Внутри OVER — порядок вычисления (нумерации, накопления); в конце запроса — порядок вывода. Это независимые вещи.
- Пробуют фильтровать по окну в WHERE (
WHERE ROW_NUMBER() … = 1) — ошибка: оборачивайте в подзапрос и фильтруйте снаружи. - Берут ROW_NUMBER там, где важны равные значения. Два заказа в одну секунду — «последним» станет случайный. Либо уточните ORDER BY (добавьте id), либо осознанно возьмите RANK.
Что учить дальше. Окна постоянно натыкаются на пустые значения — самое время разобраться с ними: NULL и типы данных.