Оконные функции в SQL обычно начинаются безобидно: SUM() OVER (...), ROW_NUMBER(), LAG(), LEAD() — и кажется, что всё понятно. Мы берём строки, смотрим на соседние строки, считаем сумму или номер, но не схлопываем результат в одну строку, как это делает обычный GROUP BY.
Но как только появляются задачи вроде «посчитать накопительную сумму» или «найти средний чек за последние три заказа», в игру входит важная деталь — рамка окна.
Рамка отвечает на вопрос:
какие именно строки из окна участвуют в расчёте для текущей строки?
Это звучит как мелочь, но именно из-за рамок появляются самые тихие и неприятные ошибки. Например, LAST_VALUE() может возвращать вовсе не последнее значение в группе, а значение текущей строки. Запрос выполнится без ошибок, данные будут выглядеть правдоподобно — и баг спокойно уедет в отчёт.
Разберёмся спокойно и по порядку.
Допустим, у нас есть таблица заказов:
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id bigint NOT NULL,
created_at date NOT NULL,
amount numeric(10,2) NOT NULL
);
В ней хранится идентификатор заказа, клиент, дата создания и сумма заказа.
Из чего состоит окно
Оконное выражение обычно собирается из трёх частей:
PARTITION BY — делит строки на группы.
ORDER BY — задаёт порядок строк внутри группы.
- Рамка — уточняет, какие строки относительно текущей участвуют в расчёте.
Например:
SELECT
id,
created_at,
amount,
SUM(amount) OVER (
ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders;
Здесь мы считаем накопительную сумму заказов.
Фраза:
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
означает:
возьми все строки от начала окна до текущей строки включительно.
То есть для первой строки сумма будет только по первой строке. Для второй — по первой и второй. Для третьей — по первым трём. Так и получается нарастающий итог.
Основные границы рамки
У рамки есть несколько стандартных границ:
UNBOUNDED PRECEDING — от самого начала окна.
N PRECEDING — на N строк назад.
CURRENT ROW — текущая строка.
N FOLLOWING — на N строк вперёд.
UNBOUNDED FOLLOWING — до самого конца окна.
На человеческом языке это можно представить так:
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
Значит:
текущая строка плюс две строки перед ней.
А вот так:
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
Значит:
одна строка до текущей, сама текущая строка и одна строка после неё.
Такие рамки особенно полезны для скользящих средних, сравнений с соседними периодами и аналитики по временным рядам.
Нарастающий итог через ROWS
Самый понятный пример рамки — накопительная сумма.
Представим, что нам нужно посчитать, как растёт общая сумма заказов по датам:
SELECT
id,
created_at,
amount,
SUM(amount) OVER (
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
ORDER BY created_at, id;
Результат читается так:
| Заказ |
Сумма заказа |
Нарастающий итог |
| первый |
500 |
500 |
| второй |
700 |
1200 |
| третий |
300 |
1500 |
Каждая следующая строка добавляет свою сумму к предыдущему итогу.
Обратите внимание на ORDER BY created_at, id.
Почему здесь нужен id?
Потому что у нескольких заказов может быть одна и та же дата. Например, два заказа пришли 2026-01-10. Если сортировать только по created_at, база данных не обязана каждый раз ставить эти два заказа в одном и том же порядке.
Сегодня первым окажется заказ на 500, завтра — заказ на 700. В итоге промежуточные значения накопительной суммы могут «плавать».
Поэтому хорошее правило такое:
в оконных функциях добавляйте в ORDER BY уникальный тай-брейкер, например id.
Так результат будет детерминированным: одинаковые входные данные дадут одинаковый порядок и одинаковый расчёт.
Нарастающий итог отдельно по каждому клиенту
Теперь усложним задачу.
Нужно посчитать накопительную сумму заказов не по всей таблице, а отдельно для каждого клиента.
Для этого добавляем PARTITION BY:
SELECT
customer_id,
id,
created_at,
amount,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS customer_running_total
FROM orders
ORDER BY customer_id, created_at, id;
Теперь окно работает не по всей таблице сразу, а внутри каждого клиента.
То есть для клиента 10 будет свой накопительный итог, для клиента 20 — свой, для клиента 30 — свой.
Можно представить, что SQL сначала раскладывает заказы по отдельным стопкам:
- заказы первого клиента;
- заказы второго клиента;
- заказы третьего клиента.
А потом внутри каждой стопки считает накопительную сумму по датам.
Скользящее среднее за последние 3 строки
Ещё одна частая задача — скользящее среднее.
Например, мы хотим для каждого заказа посчитать среднюю сумму по текущему заказу и двум предыдущим.
Для этого нужна рамка фиксированной ширины:
SELECT
id,
created_at,
amount,
AVG(amount) OVER (
ORDER BY created_at, id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3
FROM orders
ORDER BY created_at, id;
Фраза:
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
означает:
возьми максимум три строки: две предыдущие и текущую.
Допустим, суммы заказов идут так:
| Строка |
Сумма |
Какие строки попали в расчёт |
| 1 |
100 |
100 |
| 2 |
200 |
100, 200 |
| 3 |
300 |
100, 200, 300 |
| 4 |
400 |
200, 300, 400 |
| 5 |
500 |
300, 400, 500 |
Для первой строки в рамке только одна строка. Для второй — две. Начиная с третьей — уже полноценные три строки.
Это важный момент: в начале окна рамка может быть неполной. AVG() спокойно это обработает и поделит сумму на фактическое количество строк.
Но если по бизнес-логике вам нужно показывать среднее только там, где есть полные три строки, можно дополнительно посчитать количество строк в такой же рамке:
SELECT
id,
created_at,
amount,
CASE
WHEN COUNT(*) OVER (
ORDER BY created_at, id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) = 3
THEN AVG(amount) OVER (
ORDER BY created_at, id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
)
END AS moving_avg_3_full
FROM orders
ORDER BY created_at, id;
В первых двух строках получится NULL, потому что полного окна из трёх строк ещё нет.
Центрированное скользящее среднее
Иногда нужно смотреть не только назад, но и вокруг текущей строки.
Например:
SELECT
id,
created_at,
amount,
AVG(amount) OVER (
ORDER BY created_at, id
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
) AS centered_avg_3
FROM orders
ORDER BY created_at, id;
Здесь для каждой строки берётся:
- одна предыдущая строка;
- текущая строка;
- одна следующая строка.
Такое среднее называют центрированным, потому что текущая строка находится в центре рамки.
Для отчётов по заказам это встречается реже, зато часто используется в аналитике метрик: сгладить график посещаемости, выручки, количества регистраций или заявок.
ROWS и RANGE: похожи внешне, но работают по-разному
Теперь самая важная развилка.
В SQL есть разные типы рамок. Чаще всего вы встретите ROWS и RANGE.
Они похожи по синтаксису, но смысл у них разный.
ROWS считает физические строки.
RANGE работает по значениям из ORDER BY и учитывает строки с одинаковым значением сортировки.
Такие строки с одинаковым значением сортировки называют peers. По-русски можно думать о них как о «равных соседях».
Посмотрим на пример:
SELECT
id,
created_at,
amount,
SUM(amount) OVER (
ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS total_by_rows,
SUM(amount) OVER (
ORDER BY created_at
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS total_by_range
FROM orders
ORDER BY created_at, id;
Представим, что в таблице есть два заказа за один день:
| id |
created_at |
amount |
| 1 |
2026-01-09 |
100 |
| 2 |
2026-01-10 |
200 |
| 3 |
2026-01-10 |
300 |
| 4 |
2026-01-11 |
400 |
Для ROWS строки идут одна за другой:
| id |
amount |
total_by_rows |
| 1 |
100 |
100 |
| 2 |
200 |
300 |
| 3 |
300 |
600 |
| 4 |
400 |
1000 |
А для RANGE две строки с датой 2026-01-10 считаются равными по created_at. Поэтому обе получают один и тот же итог:
| id |
amount |
total_by_range |
| 1 |
100 |
100 |
| 2 |
200 |
600 |
| 3 |
300 |
600 |
| 4 |
400 |
1000 |
Вот отсюда часто появляются «странные дубли» в накопительных итогах.
Разработчик ожидает, что сумма будет расти на каждой строке, а она вдруг одинаковая для нескольких строк подряд. Запрос не сломан — просто используется не та рамка.
Для накопительных итогов по строкам обычно безопаснее явно писать ROWS.
RANGE для настоящего временного окна
При этом RANGE не плохой. У него просто другая задача.
Например, в PostgreSQL можно считать не «последние 7 строк», а именно «последние 7 календарных дней»:
SELECT
id,
created_at,
amount,
SUM(amount) OVER (
ORDER BY created_at
RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW
) AS amount_last_7_days
FROM orders
ORDER BY created_at, id;
Это уже не окно по количеству строк, а окно по диапазону значений даты.
Разница большая.
Если использовать ROWS BETWEEN 6 PRECEDING AND CURRENT ROW, SQL возьмёт текущую строку и 6 предыдущих заказов. Но эти 7 заказов могут быть сделаны за один день, за неделю или за три месяца.
А RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW берёт строки именно за календарный диапазон. Если в какие-то дни заказов не было, это нормально: окно всё равно остаётся семидневным по времени.
Что такое GROUPS
Кроме ROWS и RANGE, в PostgreSQL есть ещё GROUPS.
GROUPS считает не отдельные строки и не диапазон значений, а группы равных значений из ORDER BY.
Например, если сортировка идёт по дате, то все заказы за один день образуют одну peer-группу. Рамка GROUPS двигается не по отдельным заказам, а по таким группам.
Пример:
SELECT
id,
created_at,
amount,
SUM(amount) OVER (
ORDER BY created_at
GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW
) AS amount_current_and_prev_day
FROM orders
ORDER BY created_at, id;
Такой запрос считает сумму по текущей группе дат и предыдущей группе дат.
Если за 2026-01-10 было пять заказов, они считаются одной группой. Это удобно, когда вы хотите двигаться по «ступенькам» значений, а не по каждой строке отдельно.
Важное отличие между СУБД
Оконные функции есть во многих современных базах данных, но детали рамок отличаются.
В PostgreSQL поддерживаются ROWS, RANGE и GROUPS, а также временные диапазоны вроде RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW.
В MySQL 8.0 есть оконные функции и рамки ROWS и RANGE, но возможностей меньше: например, GROUPS там нет.
В ClickHouse оконные функции тоже есть, но для скользящих расчётов по времени часто приходится внимательно смотреть ограничения конкретной версии и выбирать подходящий инструмент: обычные оконные рамки, предварительную агрегацию, массивы или специализированные функции.
Главный практический совет простой:
если пишете учебный или рабочий SQL под конкретную СУБД, проверяйте поддержку рамок именно в её документации.
Особенно если используете RANGE, интервалы и GROUPS.
Ловушка LAST_VALUE
Теперь главный подвох, ради которого рамки точно стоит понимать.
Посмотрим на такой запрос:
SELECT
id,
created_at,
amount,
LAST_VALUE(amount) OVER (
ORDER BY created_at, id
) AS last_amount
FROM orders
ORDER BY created_at, id;
На первый взгляд кажется: LAST_VALUE(amount) должен вернуть сумму последнего заказа.
Но часто результат удивляет: функция возвращает сумму текущей строки.
Почему?
Потому что при наличии ORDER BY, если рамка не указана явно, SQL использует рамку по умолчанию:
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
То есть окно идёт от начала до текущей строки, а не до конца всей группы.
А LAST_VALUE() возвращает последнее значение внутри текущей рамки.
Если рамка заканчивается на текущей строке, то последняя строка рамки — это и есть текущая строка.
Получается такая логика:
| Текущая строка |
Что входит в рамку |
Что вернёт LAST_VALUE() |
| 1 |
строки 1..1 |
значение строки 1 |
| 2 |
строки 1..2 |
значение строки 2 |
| 3 |
строки 1..3 |
значение строки 3 |
Функция не ошиблась. Она честно сделала то, что ей сказала рамка.
Просто мы ожидали другую рамку.
Как правильно использовать LAST_VALUE
Если нужно получить последнее значение по всему окну, рамку надо явно протянуть до конца:
SELECT
id,
created_at,
amount,
LAST_VALUE(amount) OVER (
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_amount
FROM orders
ORDER BY created_at, id;
Теперь рамка означает:
от начала окна до конца окна.
И LAST_VALUE() действительно увидит последнюю строку.
Если нужно последнее значение отдельно по каждому клиенту, добавляем PARTITION BY:
SELECT
customer_id,
id,
created_at,
amount,
LAST_VALUE(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS customer_last_amount
FROM orders
ORDER BY customer_id, created_at, id;
Теперь для каждого клиента будет показана сумма его последнего заказа.
Альтернативы LAST_VALUE
Иногда можно обойтись без LAST_VALUE().
Например, если нужно значение последнего заказа по дате, можно взять FIRST_VALUE() с обратной сортировкой:
SELECT
customer_id,
id,
created_at,
amount,
FIRST_VALUE(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at DESC, id DESC
) AS customer_last_amount
FROM orders
ORDER BY customer_id, created_at, id;
Здесь последний заказ становится первым из-за сортировки по убыванию.
Но и тут нужно помнить: если вы используете оконные функции с порядком, порядок должен быть детерминированным. Поэтому рядом с created_at DESC добавлен id DESC.
Если нужна не сумма именно последнего заказа, а просто максимальная сумма заказа клиента, это уже другая задача. Тогда можно использовать MAX():
SELECT
customer_id,
id,
created_at,
amount,
MAX(amount) OVER (
PARTITION BY customer_id
) AS customer_max_amount
FROM orders
ORDER BY customer_id, created_at, id;
Но важно не путать:
- последний заказ — это заказ, который позже по времени;
- максимальный заказ — это заказ с самой большой суммой.
Это разные вопросы и разные запросы.
Рамка по умолчанию: почему лучше не надеяться на магию
Одна из главных причин ошибок с окнами — надежда на поведение по умолчанию.
Когда вы пишете оконную функцию без ORDER BY, окно обычно охватывает всю партицию.
Например:
SELECT
customer_id,
id,
amount,
SUM(amount) OVER (
PARTITION BY customer_id
) AS customer_total
FROM orders;
Здесь нет порядка, поэтому сумма считается по всем заказам клиента.
Но как только появляется ORDER BY, поведение меняется: база начинает учитывать рамку до текущей строки.
Например:
SELECT
customer_id,
id,
created_at,
amount,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, id
) AS customer_running_total
FROM orders;
Во многих случаях это даст накопительный итог. Но лучше не оставлять рамку неявной, особенно в учебном, командном и продакшен-коде.
Гораздо понятнее так:
SELECT
customer_id,
id,
created_at,
amount,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS customer_running_total
FROM orders;
Да, запрос стал на одну строку длиннее. Зато следующий человек сразу увидит, что вы считаете именно накопительную сумму по строкам.
Как выбирать рамку на практике
Для большинства рабочих задач можно держать в голове простую шпаргалку.
Для накопительной суммы по строкам:
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
Для скользящего окна по последним N строкам:
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
Для центрированного окна:
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
Для значения по всей партиции, особенно с LAST_VALUE():
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
Для настоящего временного диапазона в PostgreSQL:
RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW
Самое важное — сначала словами сформулировать задачу.
Не «надо оконную функцию», а конкретно:
- «хочу сумму от начала истории до текущего заказа»;
- «хочу среднее по текущей и двум предыдущим строкам»;
- «хочу сумму за последние 7 календарных дней»;
- «хочу последнее значение по всему клиенту».
После такой формулировки рамка почти всегда выбирается сама.
Полный пример: отчёт по заказам клиента
Соберём несколько расчётов в одном запросе.
SELECT
customer_id,
id,
created_at,
amount,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total,
AVG(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3,
LAST_VALUE(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_order_amount
FROM orders
ORDER BY customer_id, created_at, id;
Что здесь происходит:
running_total считает накопительную сумму заказов клиента.
moving_avg_3 считает среднюю сумму по текущему заказу и двум предыдущим заказам клиента.
last_order_amount показывает сумму последнего заказа клиента во всех строках этого клиента.
И всё это делается без GROUP BY, потому что оконные функции не схлопывают строки. Каждая строка заказа остаётся в результате, просто рядом появляются аналитические значения.
Частые ошибки с рамками
Первая ошибка — не указывать рамку там, где она важна.
Например, написать LAST_VALUE() и ожидать последнее значение по всей группе. Если рамка заканчивается на текущей строке, результат будет другим.
Вторая ошибка — использовать RANGE там, где нужен ROWS.
Если в ORDER BY есть повторяющиеся значения, RANGE может давать одинаковый результат для нескольких строк подряд. Для накопительного итога по каждой физической строке чаще нужен ROWS.
Третья ошибка — забывать про тай-брейкер.
ORDER BY created_at
может быть недостаточно, если в один день есть несколько заказов.
Лучше:
ORDER BY created_at, id
Четвёртая ошибка — путать «последнее» и «максимальное».
LAST_VALUE() связано с порядком строк. MAX() связано с самым большим значением. Это разные смыслы.
Главное из статьи
Рамка окна определяет, какие строки участвуют в расчёте оконной функции для текущей строки.
PARTITION BY делит данные на группы, ORDER BY задаёт порядок внутри группы, а рамка уточняет диапазон строк относительно текущей.
Для нарастающих итогов обычно используют:
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
Для скользящего среднего по текущей и двум предыдущим строкам:
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
ROWS считает физические строки, а RANGE работает по значениям из ORDER BY и объединяет строки с одинаковым значением сортировки.
Если у нескольких строк одинаковый created_at, рамка RANGE может дать одинаковый накопительный итог для всех этих строк.
LAST_VALUE() часто возвращает не то, что ожидают, потому что рамка по умолчанию при ORDER BY заканчивается на текущей строке.
Для корректного LAST_VALUE() по всей партиции обычно пишут:
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
И главное правило для спокойной жизни:
не надейтесь на рамку по умолчанию в важных запросах — указывайте её явно.
Оконные функции в SQL обычно начинаются безобидно:
SUM() OVER (...),ROW_NUMBER(),LAG(),LEAD()— и кажется, что всё понятно. Мы берём строки, смотрим на соседние строки, считаем сумму или номер, но не схлопываем результат в одну строку, как это делает обычныйGROUP BY.Но как только появляются задачи вроде «посчитать накопительную сумму» или «найти средний чек за последние три заказа», в игру входит важная деталь — рамка окна.
Рамка отвечает на вопрос:
Это звучит как мелочь, но именно из-за рамок появляются самые тихие и неприятные ошибки. Например,
LAST_VALUE()может возвращать вовсе не последнее значение в группе, а значение текущей строки. Запрос выполнится без ошибок, данные будут выглядеть правдоподобно — и баг спокойно уедет в отчёт.Разберёмся спокойно и по порядку.
Допустим, у нас есть таблица заказов:
CREATE TABLE orders ( id bigint PRIMARY KEY, customer_id bigint NOT NULL, created_at date NOT NULL, amount numeric(10,2) NOT NULL );В ней хранится идентификатор заказа, клиент, дата создания и сумма заказа.
Из чего состоит окно
Оконное выражение обычно собирается из трёх частей:
PARTITION BY— делит строки на группы.ORDER BY— задаёт порядок строк внутри группы.Например:
SELECT id, created_at, amount, SUM(amount) OVER ( ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total FROM orders;Здесь мы считаем накопительную сумму заказов.
Фраза:
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWозначает:
То есть для первой строки сумма будет только по первой строке. Для второй — по первой и второй. Для третьей — по первым трём. Так и получается нарастающий итог.
Основные границы рамки
У рамки есть несколько стандартных границ:
UNBOUNDED PRECEDING— от самого начала окна.N PRECEDING— на N строк назад.CURRENT ROW— текущая строка.N FOLLOWING— на N строк вперёд.UNBOUNDED FOLLOWING— до самого конца окна.На человеческом языке это можно представить так:
ROWS BETWEEN 2 PRECEDING AND CURRENT ROWЗначит:
А вот так:
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWINGЗначит:
Такие рамки особенно полезны для скользящих средних, сравнений с соседними периодами и аналитики по временным рядам.
Нарастающий итог через ROWS
Самый понятный пример рамки — накопительная сумма.
Представим, что нам нужно посчитать, как растёт общая сумма заказов по датам:
SELECT id, created_at, amount, SUM(amount) OVER ( ORDER BY created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total FROM orders ORDER BY created_at, id;Результат читается так:
Каждая следующая строка добавляет свою сумму к предыдущему итогу.
Обратите внимание на
ORDER BY created_at, id.Почему здесь нужен
id?Потому что у нескольких заказов может быть одна и та же дата. Например, два заказа пришли
2026-01-10. Если сортировать только поcreated_at, база данных не обязана каждый раз ставить эти два заказа в одном и том же порядке.Сегодня первым окажется заказ на 500, завтра — заказ на 700. В итоге промежуточные значения накопительной суммы могут «плавать».
Поэтому хорошее правило такое:
Так результат будет детерминированным: одинаковые входные данные дадут одинаковый порядок и одинаковый расчёт.
Нарастающий итог отдельно по каждому клиенту
Теперь усложним задачу.
Нужно посчитать накопительную сумму заказов не по всей таблице, а отдельно для каждого клиента.
Для этого добавляем
PARTITION BY:SELECT customer_id, id, created_at, amount, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS customer_running_total FROM orders ORDER BY customer_id, created_at, id;Теперь окно работает не по всей таблице сразу, а внутри каждого клиента.
То есть для клиента 10 будет свой накопительный итог, для клиента 20 — свой, для клиента 30 — свой.
Можно представить, что SQL сначала раскладывает заказы по отдельным стопкам:
А потом внутри каждой стопки считает накопительную сумму по датам.
Скользящее среднее за последние 3 строки
Ещё одна частая задача — скользящее среднее.
Например, мы хотим для каждого заказа посчитать среднюю сумму по текущему заказу и двум предыдущим.
Для этого нужна рамка фиксированной ширины:
SELECT id, created_at, amount, AVG(amount) OVER ( ORDER BY created_at, id ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS moving_avg_3 FROM orders ORDER BY created_at, id;Фраза:
ROWS BETWEEN 2 PRECEDING AND CURRENT ROWозначает:
Допустим, суммы заказов идут так:
Для первой строки в рамке только одна строка. Для второй — две. Начиная с третьей — уже полноценные три строки.
Это важный момент: в начале окна рамка может быть неполной.
AVG()спокойно это обработает и поделит сумму на фактическое количество строк.Но если по бизнес-логике вам нужно показывать среднее только там, где есть полные три строки, можно дополнительно посчитать количество строк в такой же рамке:
SELECT id, created_at, amount, CASE WHEN COUNT(*) OVER ( ORDER BY created_at, id ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) = 3 THEN AVG(amount) OVER ( ORDER BY created_at, id ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) END AS moving_avg_3_full FROM orders ORDER BY created_at, id;В первых двух строках получится
NULL, потому что полного окна из трёх строк ещё нет.Центрированное скользящее среднее
Иногда нужно смотреть не только назад, но и вокруг текущей строки.
Например:
SELECT id, created_at, amount, AVG(amount) OVER ( ORDER BY created_at, id ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING ) AS centered_avg_3 FROM orders ORDER BY created_at, id;Здесь для каждой строки берётся:
Такое среднее называют центрированным, потому что текущая строка находится в центре рамки.
Для отчётов по заказам это встречается реже, зато часто используется в аналитике метрик: сгладить график посещаемости, выручки, количества регистраций или заявок.
ROWS и RANGE: похожи внешне, но работают по-разному
Теперь самая важная развилка.
В SQL есть разные типы рамок. Чаще всего вы встретите
ROWSиRANGE.Они похожи по синтаксису, но смысл у них разный.
ROWSсчитает физические строки.RANGEработает по значениям изORDER BYи учитывает строки с одинаковым значением сортировки.Такие строки с одинаковым значением сортировки называют peers. По-русски можно думать о них как о «равных соседях».
Посмотрим на пример:
SELECT id, created_at, amount, SUM(amount) OVER ( ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS total_by_rows, SUM(amount) OVER ( ORDER BY created_at RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS total_by_range FROM orders ORDER BY created_at, id;Представим, что в таблице есть два заказа за один день:
Для
ROWSстроки идут одна за другой:А для
RANGEдве строки с датой2026-01-10считаются равными поcreated_at. Поэтому обе получают один и тот же итог:Вот отсюда часто появляются «странные дубли» в накопительных итогах.
Разработчик ожидает, что сумма будет расти на каждой строке, а она вдруг одинаковая для нескольких строк подряд. Запрос не сломан — просто используется не та рамка.
Для накопительных итогов по строкам обычно безопаснее явно писать
ROWS.RANGE для настоящего временного окна
При этом
RANGEне плохой. У него просто другая задача.Например, в PostgreSQL можно считать не «последние 7 строк», а именно «последние 7 календарных дней»:
SELECT id, created_at, amount, SUM(amount) OVER ( ORDER BY created_at RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW ) AS amount_last_7_days FROM orders ORDER BY created_at, id;Это уже не окно по количеству строк, а окно по диапазону значений даты.
Разница большая.
Если использовать
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW, SQL возьмёт текущую строку и 6 предыдущих заказов. Но эти 7 заказов могут быть сделаны за один день, за неделю или за три месяца.А
RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROWберёт строки именно за календарный диапазон. Если в какие-то дни заказов не было, это нормально: окно всё равно остаётся семидневным по времени.Что такое GROUPS
Кроме
ROWSиRANGE, в PostgreSQL есть ещёGROUPS.GROUPSсчитает не отдельные строки и не диапазон значений, а группы равных значений изORDER BY.Например, если сортировка идёт по дате, то все заказы за один день образуют одну peer-группу. Рамка
GROUPSдвигается не по отдельным заказам, а по таким группам.Пример:
SELECT id, created_at, amount, SUM(amount) OVER ( ORDER BY created_at GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW ) AS amount_current_and_prev_day FROM orders ORDER BY created_at, id;Такой запрос считает сумму по текущей группе дат и предыдущей группе дат.
Если за
2026-01-10было пять заказов, они считаются одной группой. Это удобно, когда вы хотите двигаться по «ступенькам» значений, а не по каждой строке отдельно.Важное отличие между СУБД
Оконные функции есть во многих современных базах данных, но детали рамок отличаются.
В PostgreSQL поддерживаются
ROWS,RANGEиGROUPS, а также временные диапазоны вродеRANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW.В MySQL 8.0 есть оконные функции и рамки
ROWSиRANGE, но возможностей меньше: например,GROUPSтам нет.В ClickHouse оконные функции тоже есть, но для скользящих расчётов по времени часто приходится внимательно смотреть ограничения конкретной версии и выбирать подходящий инструмент: обычные оконные рамки, предварительную агрегацию, массивы или специализированные функции.
Главный практический совет простой:
Особенно если используете
RANGE, интервалы иGROUPS.Ловушка LAST_VALUE
Теперь главный подвох, ради которого рамки точно стоит понимать.
Посмотрим на такой запрос:
SELECT id, created_at, amount, LAST_VALUE(amount) OVER ( ORDER BY created_at, id ) AS last_amount FROM orders ORDER BY created_at, id;На первый взгляд кажется:
LAST_VALUE(amount)должен вернуть сумму последнего заказа.Но часто результат удивляет: функция возвращает сумму текущей строки.
Почему?
Потому что при наличии
ORDER BY, если рамка не указана явно, SQL использует рамку по умолчанию:RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWТо есть окно идёт от начала до текущей строки, а не до конца всей группы.
А
LAST_VALUE()возвращает последнее значение внутри текущей рамки.Если рамка заканчивается на текущей строке, то последняя строка рамки — это и есть текущая строка.
Получается такая логика:
LAST_VALUE()Функция не ошиблась. Она честно сделала то, что ей сказала рамка.
Просто мы ожидали другую рамку.
Как правильно использовать LAST_VALUE
Если нужно получить последнее значение по всему окну, рамку надо явно протянуть до конца:
SELECT id, created_at, amount, LAST_VALUE(amount) OVER ( ORDER BY created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_amount FROM orders ORDER BY created_at, id;Теперь рамка означает:
И
LAST_VALUE()действительно увидит последнюю строку.Если нужно последнее значение отдельно по каждому клиенту, добавляем
PARTITION BY:SELECT customer_id, id, created_at, amount, LAST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS customer_last_amount FROM orders ORDER BY customer_id, created_at, id;Теперь для каждого клиента будет показана сумма его последнего заказа.
Альтернативы LAST_VALUE
Иногда можно обойтись без
LAST_VALUE().Например, если нужно значение последнего заказа по дате, можно взять
FIRST_VALUE()с обратной сортировкой:SELECT customer_id, id, created_at, amount, FIRST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY created_at DESC, id DESC ) AS customer_last_amount FROM orders ORDER BY customer_id, created_at, id;Здесь последний заказ становится первым из-за сортировки по убыванию.
Но и тут нужно помнить: если вы используете оконные функции с порядком, порядок должен быть детерминированным. Поэтому рядом с
created_at DESCдобавленid DESC.Если нужна не сумма именно последнего заказа, а просто максимальная сумма заказа клиента, это уже другая задача. Тогда можно использовать
MAX():SELECT customer_id, id, created_at, amount, MAX(amount) OVER ( PARTITION BY customer_id ) AS customer_max_amount FROM orders ORDER BY customer_id, created_at, id;Но важно не путать:
Это разные вопросы и разные запросы.
Рамка по умолчанию: почему лучше не надеяться на магию
Одна из главных причин ошибок с окнами — надежда на поведение по умолчанию.
Когда вы пишете оконную функцию без
ORDER BY, окно обычно охватывает всю партицию.Например:
SELECT customer_id, id, amount, SUM(amount) OVER ( PARTITION BY customer_id ) AS customer_total FROM orders;Здесь нет порядка, поэтому сумма считается по всем заказам клиента.
Но как только появляется
ORDER BY, поведение меняется: база начинает учитывать рамку до текущей строки.Например:
SELECT customer_id, id, created_at, amount, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, id ) AS customer_running_total FROM orders;Во многих случаях это даст накопительный итог. Но лучше не оставлять рамку неявной, особенно в учебном, командном и продакшен-коде.
Гораздо понятнее так:
SELECT customer_id, id, created_at, amount, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS customer_running_total FROM orders;Да, запрос стал на одну строку длиннее. Зато следующий человек сразу увидит, что вы считаете именно накопительную сумму по строкам.
Как выбирать рамку на практике
Для большинства рабочих задач можно держать в голове простую шпаргалку.
Для накопительной суммы по строкам:
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWДля скользящего окна по последним N строкам:
ROWS BETWEEN 2 PRECEDING AND CURRENT ROWДля центрированного окна:
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWINGДля значения по всей партиции, особенно с
LAST_VALUE():ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWINGДля настоящего временного диапазона в PostgreSQL:
RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROWСамое важное — сначала словами сформулировать задачу.
Не «надо оконную функцию», а конкретно:
После такой формулировки рамка почти всегда выбирается сама.
Полный пример: отчёт по заказам клиента
Соберём несколько расчётов в одном запросе.
SELECT customer_id, id, created_at, amount, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total, AVG(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, id ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS moving_avg_3, LAST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_order_amount FROM orders ORDER BY customer_id, created_at, id;Что здесь происходит:
running_totalсчитает накопительную сумму заказов клиента.moving_avg_3считает среднюю сумму по текущему заказу и двум предыдущим заказам клиента.last_order_amountпоказывает сумму последнего заказа клиента во всех строках этого клиента.И всё это делается без
GROUP BY, потому что оконные функции не схлопывают строки. Каждая строка заказа остаётся в результате, просто рядом появляются аналитические значения.Частые ошибки с рамками
Первая ошибка — не указывать рамку там, где она важна.
Например, написать
LAST_VALUE()и ожидать последнее значение по всей группе. Если рамка заканчивается на текущей строке, результат будет другим.Вторая ошибка — использовать
RANGEтам, где нуженROWS.Если в
ORDER BYесть повторяющиеся значения,RANGEможет давать одинаковый результат для нескольких строк подряд. Для накопительного итога по каждой физической строке чаще нуженROWS.Третья ошибка — забывать про тай-брейкер.
ORDER BY created_atможет быть недостаточно, если в один день есть несколько заказов.
Лучше:
ORDER BY created_at, idЧетвёртая ошибка — путать «последнее» и «максимальное».
LAST_VALUE()связано с порядком строк.MAX()связано с самым большим значением. Это разные смыслы.Главное из статьи
Рамка окна определяет, какие строки участвуют в расчёте оконной функции для текущей строки.
PARTITION BYделит данные на группы,ORDER BYзадаёт порядок внутри группы, а рамка уточняет диапазон строк относительно текущей.Для нарастающих итогов обычно используют:
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWДля скользящего среднего по текущей и двум предыдущим строкам:
ROWS BETWEEN 2 PRECEDING AND CURRENT ROWROWSсчитает физические строки, аRANGEработает по значениям изORDER BYи объединяет строки с одинаковым значением сортировки.Если у нескольких строк одинаковый
created_at, рамкаRANGEможет дать одинаковый накопительный итог для всех этих строк.LAST_VALUE()часто возвращает не то, что ожидают, потому что рамка по умолчанию приORDER BYзаканчивается на текущей строке.Для корректного
LAST_VALUE()по всей партиции обычно пишут:ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWINGИ главное правило для спокойной жизни: