sqlpostgresqlwindow-functionsanalytics

SQL Window Frames: ROWS/RANGE, Running Totals, and the LAST_VALUE Trap

Understand how ROWS and RANGE BETWEEN frames work, build running totals and moving averages, and avoid the classic LAST_VALUE default-frame trap.

3 min de lectureReferencesql · postgresql · window-functions · analytics
Cet article est actuellement en russe — la traduction en anglais est en cours.

Оконные функции вы наверняка уже встречали: SUM() OVER (...), ROW_NUMBER() и компания. Но как только дело доходит до нарастающих итогов и скользящих средних, появляется третий компонент окна, который многие пропускают, — рамка (frame). Именно рамка решает, какие именно строки внутри партиции участвуют в расчёте для текущей строки. Непонимание рамки порождает тихие, неочевидные баги: например, LAST_VALUE, который упрямо возвращает не то значение. Разберёмся по порядку на схеме orders.

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 (как упорядочиваем внутри группы) и рамка (ROWS/RANGE/GROUPS BETWEEN ...). Рамка задаёт диапазон строк относительно текущей.

SELECT
    id,
    created_at,
    amount,
    SUM(amount) OVER (
        ORDER BY created_at
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW  -- рамка
    ) AS running_total
FROM orders;

Ключевые границы рамки:

  • UNBOUNDED PRECEDING — от начала партиции;
  • N PRECEDING / N FOLLOWING — N строк назад/вперёд;
  • CURRENT ROW — текущая строка;
  • UNBOUNDED FOLLOWING — до конца партиции.

Важно: если вы написали ORDER BY, но не указали рамку, СУБД подставит её сама — и почти всегда не ту, что вы ждёте. Об этом ниже.

Нарастающий итог: ROWS

Классика — накопительная сумма заказов по дате. Рамка «от начала до текущей строки» делает ровно это:

SELECT
    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;

Обратите внимание на id в ORDER BY — он делает порядок детерминированным. Если две строки имеют одинаковый created_at, без тай-брейкера порядок между ними не определён, и результат может «плавать» от запуска к запуску.

Нарастающий итог по каждому клиенту отдельно — добавляем PARTITION BY:

SELECT
    customer_id,
    created_at,
    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;

Скользящее среднее: окно фиксированной ширины

Скользящее среднее за 3 строки (текущая + две предыдущие) — это рамка 2 PRECEDING AND CURRENT ROW:

SELECT
    created_at,
    amount,
    AVG(amount) OVER (
        ORDER BY created_at, id
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ) AS moving_avg_3
FROM orders;

Хотите центрированное среднее (по одной строке слева и справа)? Используйте BETWEEN 1 PRECEDING AND 1 FOLLOWING.

Гоча: в начале партиции окно «недоукомплектовано» — для первой строки в нём всего одна запись, для второй — две. AVG это нормально учитывает (делит на фактическое число строк), а вот для честного среднего «только по полным окнам» придётся фильтровать или считать COUNT(*) OVER (...) и отбрасывать неполные.

ROWS против RANGE — это разные вещи

Главное различие. ROWS считает физические строки. RANGE работает по значениям колонки из ORDER BY: в рамку попадают все строки с тем же значением (peers), что и текущая.

-- Два заказа в один день: created_at = '2026-01-10'
SELECT
    created_at,
    amount,
    SUM(amount) OVER (ORDER BY created_at
        ROWS  BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS by_rows,
    SUM(amount) OVER (ORDER BY created_at
        RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS by_range
FROM orders;

Для двух строк за 2026-01-10 by_rows даст разные значения (строки идут по очереди), а by_rangeодинаковое для обеих, потому что они peers по дате и схлопываются в один шаг. Это часто и есть источник «странных» дублей в нарастающем итоге.

PostgreSQL также поддерживает диапазоны по значениям: RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW — настоящее «скользящее окно за 7 календарных дней», даже если в какие-то дни заказов не было. И ещё есть GROUPS — рамка в единицах групп peer-значений.

Различия движков: MySQL поддерживает ROWS и RANGE (с 8.0), но не GROUPS и не RANGE по INTERVAL в том же виде. ClickHouse поддерживает оконные функции, но RANGE-рамки с интервалами там ограничены — для скользящих окон по времени чаще берут ROWS или специальные функции вроде arrayJoin/groupArray. Всегда проверяйте документацию конкретной версии.

Ловушка LAST_VALUE и рамка по умолчанию

Самый коварный момент. Когда есть ORDER BY, но рамка не указана явно, по стандарту SQL действует:

RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW

То есть окно тянется только до текущей строки, а не до конца партиции. Для FIRST_VALUE это незаметно (первая строка всегда в окне), а вот LAST_VALUE ломается:

-- ОШИБКА: вернёт amount ТЕКУЩЕЙ строки, а не последней
SELECT
    created_at,
    amount,
    LAST_VALUE(amount) OVER (ORDER BY created_at, id) AS wrong_last
FROM orders;

«Последнее значение» по умолчанию — это последняя строка в рамке, а рамка кончается на текущей строке. Получаете не последний заказ, а просто текущий. Лечится явной рамкой до конца партиции:

SELECT
    created_at,
    amount,
    LAST_VALUE(amount) OVER (
        ORDER BY created_at, id
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS correct_last
FROM orders;

Альтернатива без танцев с рамкой — FIRST_VALUE с обратной сортировкой, либо MAX(...) OVER (PARTITION BY ...) без ORDER BY (тогда рамка покрывает всю партицию).

Запомните три вещи: всегда добавляйте тай-брейкер в ORDER BY; помните, что рамка по умолчанию — RANGE ... CURRENT ROW, а не «вся партиция»; и для LAST_VALUE почти всегда дописывайте ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.

Entraînez-vous sur de vrais exercices

Résolvez des exercices dans l'entraîneur SQL avec évaluation et indices instantanés.

Ouvrir l'entraîneur