У 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 и типы данных.