Главная мысль: окно считает рядом, а не вместо строки
Самое важное про оконные функции укладывается в одну фразу: оконная функция добавляет к строке новое значение, но саму строку не убирает. Это и есть всё отличие от GROUP BY. Когда вы пишете GROUP BY, движок схлопывает множество строк в одну: было десять продаж по региону — стала одна строка с суммой. Когда вы пишете оконную функцию, движок берёт те же десять строк, для каждой считает что-то по соседям и возвращает все десять, дописав к каждой ещё одну колонку.
Поэтому ментальная модель такая: GROUP BY — это «свернуть и потерять детали», окно — это «посчитать агрегат сбоку, оставив детали на месте». Если вам когда-нибудь хотелось в одном запросе показать и конкретную продажу, и долю этой продажи в сумме по региону — без двух запросов и без джойна с подзапросом — это ровно та задача, ради которой окна и придуманы.
Синтаксически окно — это любая функция с ключевым словом OVER (...) после неё. Всё, что внутри скобок OVER, описывает, какие строки считать соседями для текущей строки. Пустые скобки OVER () означают «соседи — это вообще все строки результата».
SELECT
region,
amount,
SUM(amount) OVER (PARTITION BY region) AS region_total,
amount::numeric / SUM(amount) OVER (PARTITION BY region) AS share
FROM sales;
Здесь region_total повторяется в каждой строке региона — потому что строки не схлопнулись. А share — доля конкретной продажи в сумме по её региону, посчитать которую без окна обычно означало бы отдельный подзапрос.
PARTITION BY: режем результат на независимые корзины
OVER нет PARTITION BY, вся выборка — одна большая партиция.
Ключевое отличие от GROUP BY: PARTITION BY нарезает строки на корзины только для расчёта окна, но не уменьшает число строк на выходе. Граница партиции — это «забор»: функция не видит строки из других партиций. SUM(amount) OVER (PARTITION BY region) — это сумма внутри своего региона; пересечь границу региона она не может.
Думайте о PARTITION BY как о GROUP BY, который оставляет детали. Те же поля группировки, та же логика «считаем по каждой корзине» — но результат пришивается к каждой исходной строке, а не вместо них.
Можно перечислить несколько полей: PARTITION BY region, channel создаст отдельную корзину на каждую пару «регион + канал», и окно будет считаться независимо в каждой такой паре. Это полезно держать в голове как контраст с GROUP BY: набор полей выглядит одинаково, но смысл результата разный — GROUP BY отдаст по одной строке на пару, окно отдаст все строки, дописав агрегат.
Важная деталь про производительность: чтобы посчитать окно с PARTITION BY и ORDER BY, движку нужно физически разложить строки по партициям и отсортировать каждую. В PostgreSQL это видно в плане запроса как узел WindowAgg, которому почти всегда предшествует Sort. Если несколько окон в одном SELECT используют одинаковое OVER, движок переиспользует одну сортировку на все — поэтому держать окна согласованными по PARTITION BY и ORDER BY выгодно не только для читаемости.
ORDER BY внутри OVER: задаём порядок и неявный фрейм
Дальше начинается самое тонкое. ORDER BY внутри OVER (...) — это не та сортировка, что в конце запроса. Внешний ORDER BY упорядочивает вывод. ORDER BY внутри окна задаёт порядок, в котором строки накапливаются для расчёта, и именно он включает накопительное поведение.
Сравните два выражения:
SUM(amount) OVER (PARTITION BY region)— сумма по всей партиции, одинаковая во всех строках.SUM(amount) OVER (PARTITION BY region ORDER BY sale_date)— нарастающий итог: в каждой строке сумма от начала партиции до текущей строки включительно.
Почему так? Потому что у ORDER BY внутри окна есть скрытый эффект: при наличии ORDER BY и при отсутствии явного фрейма СУБД подставляет фрейм по умолчанию — RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. То есть «от начала партиции до текущей строки». Уберите ORDER BY — фреймом станет вся партиция. Именно эта неявная подмена фрейма и превращает обычный SUM в running total, и именно поэтому она так часто удивляет новичков.
Ранжирование: ROW_NUMBER, RANK, DENSE_RANK
Ранжирующие функции требуют ORDER BY внутри окна — им нужно знать, по чему расставлять места. Они отличаются тем, как обходятся с одинаковыми значениями (ничьими).
| Функция | Что делает | Ничьи (равные значения) | Пропуски в нумерации |
|---|---|---|---|
ROW_NUMBER() | Сквозной номер строки в окне | Разные номера (порядок произвольный) | Нет |
RANK() | Спортивный ранг | Один и тот же ранг | Да (после ничьей номер прыгает) |
DENSE_RANK() | Плотный ранг | Один и тот же ранг | Нет |
NTILE(n) | Раскладывает строки на n примерно равных групп | По границам бакетов | Не применимо |
PERCENT_RANK() | Относительный ранг от 0 до 1 | Одинаковый | Не применимо |
Разница RANK и DENSE_RANK на примере оценок 100, 100, 90: RANK даст 1, 1, 3 (двойка пропущена, потому что две строки заняли первое и «второе» место), DENSE_RANK даст 1, 1, 2 (плотно, без дырки). ROW_NUMBER даст 1, 2, 3 всегда, даже при равных значениях — поэтому он подходит, когда нужен гарантированно уникальный номер, например для дедупликации: оставить «по одной строке на ключ» — это ROW_NUMBER() OVER (PARTITION BY ключ ORDER BY ...) и затем фильтр WHERE rn = 1.
Какой из трёх брать — решает задача. Нужен честный спортивный рейтинг с разрывами при ничьих — RANK. Нужны «уровни» без дырок (топ-1, топ-2, топ-3 как категории) — DENSE_RANK. Нужен технический уникальный идентификатор позиции — ROW_NUMBER. Путаница между ними — одна из самых частых причин расхождений в аналитических отчётах, когда в данных есть дубли по ключу сортировки.
Классический рецепт «топ-N в каждой группе» строится именно на ROW_NUMBER:
SELECT region, product, amount
FROM (
SELECT
region,
product,
amount,
ROW_NUMBER() OVER (
PARTITION BY region
ORDER BY amount DESC
) AS rn
FROM sales
) ranked
WHERE rn <= 3;
Внутри подзапроса каждой строке проставляется её место внутри региона (PARTITION BY region) по убыванию суммы. Внешний WHERE rn <= 3 оставляет три лучшие продажи в каждом регионе. Обратите внимание: фильтровать по rn приходится снаружи — оконные функции нельзя использовать в WHERE того же уровня, потому что они вычисляются логически после WHERE (об этом ниже).
Frame clause: ROWS против RANGE
ORDER BY внутри окна, и пишется как ROWS BETWEEN ... AND ... или RANGE BETWEEN ... AND ....
Границы фрейма:
UNBOUNDED PRECEDING— от самого начала партиции.N PRECEDING— наNстрок (или значений) назад.CURRENT ROW— текущая строка.N FOLLOWING— наNвперёд.UNBOUNDED FOLLOWING— до конца партиции.
Принципиальная и самая частая ловушка — разница между ROWS и RANGE:
ROWSсчитает физические строки:ROWS BETWEEN 2 PRECEDING AND CURRENT ROW— это ровно три строки (две предыдущие плюс текущая), что бы в них ни лежало.RANGEсчитает по значениям колонки сортировки: все строки, чьё значениеORDER BYпопадает в диапазон. При этом строки с одинаковым значением сортировки трактуются как одна группа (peer-группа) и попадают во фрейм целиком.
Отсюда коварство фрейма по умолчанию. Когда вы пишете SUM(...) OVER (ORDER BY sale_date) без явного фрейма, действует RANGE ... CURRENT ROW. Если в один день несколько продаж, RANGE включит в running total все продажи этого дня сразу — и в строках одного дня вы увидите одинаковую накопленную сумму, а не пошаговый рост. Чтобы получить честный построчный нарастающий итог, почти всегда нужен явный ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
SELECT
sale_date,
amount,
-- построчный нарастающий итог: ровно «всё до текущей строки»
SUM(amount) OVER (
ORDER BY sale_date, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total,
-- скользящее среднее по последним трём строкам
AVG(amount) OVER (
ORDER BY sale_date, id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3
FROM sales;
Добавление id во вторичный ключ сортировки делает порядок детерминированным: без него строки с одинаковой датой могут идти в произвольном порядке, и нарастающий итог станет невоспроизводимым между запусками.
LAG и LEAD: заглянуть в соседнюю строку
LAG и LEAD — это окна, которые не агрегируют, а берут значение из другой строки относительно текущей в порядке ORDER BY. LAG(x, n) смотрит на n строк назад, LEAD(x, n) — на n строк вперёд. По умолчанию n = 1. Третий аргумент — значение по умолчанию, когда заглядывать некуда (край партиции): LAG(amount, 1, 0).
Это рабочая лошадка аналитики «период к периоду»: разница с предыдущим днём, рост выручки, длительность между событиями пользователя. Без окон такое считается через self-join по сдвинутому ключу — медленно и многословно; LAG делает то же одной колонкой.
SELECT
sale_date,
amount,
LAG(amount) OVER (ORDER BY sale_date) AS prev_amount,
amount - LAG(amount) OVER (ORDER BY sale_date) AS delta,
LEAD(amount) OVER (ORDER BY sale_date) AS next_amount
FROM sales;
В самой первой строке prev_amount будет NULL — назад смотреть некуда; в последней NULL будет у next_amount. Поэтому delta в первой строке тоже NULL. Если для арифметики нужен ноль вместо NULL, задайте значение по умолчанию третьим аргументом или оберните в COALESCE.
Рядом стоят FIRST_VALUE, LAST_VALUE и NTH_VALUE — они берут не соседа по смещению, а конкретную строку внутри фрейма. Тут снова всплывает фрейм: LAST_VALUE(x) OVER (ORDER BY d) с фреймом по умолчанию вернёт текущую строку, а не последнюю в партиции, потому что фрейм заканчивается на CURRENT ROW. Чтобы получить настоящее последнее значение партиции, нужен явный ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
Порядок выполнения: почему окно нельзя в WHERE
Чтобы не ловить ошибки «column rn does not exist» в WHERE, держите в голове логический порядок шагов SQL: FROM и джойны, затем WHERE, затем GROUP BY и HAVING, затем оконные функции (на этапе SELECT), и только потом внешний ORDER BY и LIMIT.
Из этого следуют два практических вывода. Первый: окна видят строки уже после WHERE и GROUP BY — отфильтрованные строки в окно не попадут, а строки уже агрегированные GROUP BY можно окнить поверх агрегата. Второй: раз окна вычисляются на этапе SELECT, обратиться к их результату в WHERE того же запроса нельзя — он ещё не существует. Поэтому фильтрация по результату окна (топ-N, «строки с рангом 1») всегда выносится во внешний подзапрос или CTE, как в примере выше.
Куда это ведёт дальше
Оконные функции — это тот навык, после которого огромный класс задач, раньше требовавших подзапросов и self-join’ов, схлопывается в одну читаемую колонку: рейтинги, доли, нарастающие итоги, разницы период-к-периоду, дедупликация, сессионизация событий. А чтобы понимать, почему running total с RANGE ведёт себя иначе, чем с ROWS, полезно один раз увидеть, как планировщик материализует партиции и сортирует их перед оконным проходом — это уже территория внутренностей движка.
Разобраться по-настоящему помогает практика прямо в браузере. На бесплатном курсе SQL Fundamentals оконные функции, фреймы и ранжирование разобраны по шагам, с интерактивной песочницей на pglite — пишете запросы и сразу видите результат, ничего не устанавливая. Курс полностью бесплатный.
Если вам интересна не только сама команда, но и то, как СУБД исполняет окна, и где это встаёт в реальные пайплайны, посмотрите весь каталог направления Data Engineering: от SQL до планировщиков, форматов хранения и движков обработки.
CTA
Возьмите оконные функции руками, а не глазами: бесплатный курс SQL Fundamentals с песочницей в браузере — PARTITION BY, ORDER BY, фреймы и ранжирование на живых данных, без установки и без оплаты.