sqlpostgresqlwindow-functionsanalytics

Рамки оконных функций в SQL: как считать нарастающие итоги, скользящие средние и не сломать LAST_VALUE

Разбираемся, как устроены оконные рамки ROWS и RANGE BETWEEN, считаем нарастающие итоги и скользящие средние и обходим классическую ловушку с LAST_VALUE.

11 мин чтенияСправочникsql · postgresql · window-functions · analytics

Оконные функции в 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
);

В ней хранится идентификатор заказа, клиент, дата создания и сумма заказа.

Из чего состоит окно

Оконное выражение обычно собирается из трёх частей:

  1. PARTITION BY — делит строки на группы.
  2. ORDER BY — задаёт порядок строк внутри группы.
  3. Рамка — уточняет, какие строки относительно текущей участвуют в расчёте.

Например:

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-тренажёре с мгновенной проверкой и подсказками.

Открыть тренажёр