sqlоконные функцииrunning totalнарастающий итоганалитикасобеседование

Running total в SQL: нарастающий итог за 5 минут

2026-06-09 8 мин

Коротко: нарастающий итог (running total) в SQL считается оконной функцией SUM(value) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). Она суммирует все строки от начала окна до текущей включительно, не схлопывая результат — у вас остаётся столько же строк, сколько было, плюс колонка накопительной суммы. Это базовый инструмент для графиков «выручка нарастающим итогом», «количество пользователей к дате» и любых cumulative-метрик.

Running total — один из самых частых запросов на собеседовании аналитика и в реальной работе. Менеджер просит «покажи, как накапливалась выручка по дням» — и вместо самописных подзапросов аналитик в одну строку пишет оконную функцию. Разберём по шагам: от простой накопительной суммы до скользящего окна и расчёта по группам.

Что такое running total и зачем он нужен?

Running total (по-русски — нарастающий итог или накопительная сумма) — это сумма всех значений от начала ряда до текущей строки. В отличие от обычного SUM() с GROUP BY, который возвращает одно число на группу, running total оставляет все строки и добавляет колонку с накопленной суммой на каждый момент.

Пример. Есть продажи по дням:

ДатаВыручкаRunning total
2026-01-01100100
2026-01-02150250
2026-01-0380330
2026-01-04200530

Третья колонка и есть нарастающий итог: 100, потом 100+150, потом 100+150+80 и так далее. Такие метрики нужны постоянно:

Как написать running total в SQL?

Базовая конструкция — оконная функция SUM() OVER (ORDER BY ...). Полный синтаксис:

SELECT
    sale_date,
    revenue,
    SUM(revenue) OVER (
        ORDER BY sale_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS running_total
FROM daily_sales
ORDER BY sale_date;

Разберём по частям:

UNBOUNDED PRECEDING означает «без нижней границы» — то есть с начала набора. CURRENT ROW — текущая строка. Вместе они дают накопление от старта до здесь.

Нужно ли всегда писать ROWS UNBOUNDED PRECEDING?

Технически нет, но писать рамку явно — хорошая привычка. Если опустить ROWS BETWEEN ..., SQL применит рамку по умолчанию — RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. В большинстве случаев результат совпадёт, но есть ловушка с дубликатами в колонке сортировки.

Разница между ROWS и RANGE:

Если в один день несколько продаж и вы сортируете по дате, RANGE сложит все продажи этого дня в каждой из строк этого дня (одинаковое значение во всех строках дня), а ROWS будет накапливать строка за строкой. На собеседовании любят спросить эту разницу — поэтому привыкайте указывать ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW явно, чтобы поведение было предсказуемым. Подробный разбор оконных рамок есть в разделе SQL по оконным функциям.

Как считать running total по группам (партициям)?

Чаще всего нужен нарастающий итог не по всей таблице, а отдельно для каждого клиента, города или категории. Для этого добавляют PARTITION BY — он разбивает данные на независимые группы, и накопление стартует заново внутри каждой.

SELECT
    user_id,
    sale_date,
    revenue,
    SUM(revenue) OVER (
        PARTITION BY user_id
        ORDER BY sale_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS user_running_total
FROM daily_sales
ORDER BY user_id, sale_date;

Теперь для каждого user_id итог копится с нуля по его собственному хронологическому ряду. Когда user_id меняется, окно сбрасывается. Это ровно то, что нужно для отчётов вида «накопленная выручка по каждому клиенту» или «прогресс по каждому магазину».

Порядок секций в OVER фиксированный: сначала PARTITION BY, затем ORDER BY, затем рамка ROWS/RANGE. Перепутать нельзя — будет синтаксическая ошибка.

Чем running total отличается от скользящего окна (moving average)?

Это два разных, но похожих по форме окна. Разница — в нижней границе рамки.

Скользящее среднее за 7 дней пишется так:

SELECT
    sale_date,
    revenue,
    AVG(revenue) OVER (
        ORDER BY sale_date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS moving_avg_7d
FROM daily_sales
ORDER BY sale_date;
6 PRECEDING плюс CURRENT ROW — это окно из 7 строк (6 предыдущих + текущая). По мере движения вниз окно «скользит»: старые строки выпадают, новые входят. Сравнительная сводка:
ПараметрRunning totalСкользящее окно
Нижняя границаUNBOUNDED PRECEDINGN PRECEDING
Что считаетвсё с началатолько последние N
Типичная функцияSUMAVG (сглаживание тренда)
Применениеcumulative-метрикисглаживание шума, тренд

Скользящее среднее используют, чтобы убрать «зубцы» дневной метрики и увидеть тренд. Running total — чтобы показать накопление.

Можно ли посчитать running total без оконных функций?

Можно, но это антипаттерн — медленно и громоздко. Старый способ — самосоединение (self-join) или коррелированный подзапрос:

SELECT
    d1.sale_date,
    d1.revenue,
    SUM(d2.revenue) AS running_total
FROM daily_sales d1
JOIN daily_sales d2 ON d2.sale_date <= d1.sale_date
GROUP BY d1.sale_date, d1.revenue
ORDER BY d1.sale_date;

Проблема: для каждой строки заново суммируются все предыдущие — сложность O(n²). На таблице в миллион строк это десятки секунд или таймаут. Оконная функция делает то же самое за один проход, O(n), и читается в разы понятнее. В современных СУБД (PostgreSQL, ClickHouse, BigQuery, Snowflake) оконные функции есть везде — самосоединение для running total использовать не нужно. Это типичный пример из подборки антипаттернов SQL, которые разбирают на собесах.

Как обрабатывать пропуски в датах и NULL?

Две частые проблемы в running total на реальных данных.

Пропущенные дни. Если в какой-то день продаж не было, строки за этот день просто нет — и на графике накопления получится «горизонтальная полка» без точки. Если нужен итог на каждую календарную дату, сначала генерируют полный календарь (generate_series в PostgreSQL) и делают LEFT JOIN с продажами, подставляя ноль вместо отсутствующих дней через COALESCE(revenue, 0). Тогда running total корректно «застывает» в дни без продаж.

NULL в значениях. SUM() игнорирует NULL — он не обнуляет накопление, а просто пропускает строку при сложении. Обычно это и нужно. Но если NULL должен трактоваться как ноль для непрерывности ряда, оберните значение: SUM(COALESCE(revenue, 0)) OVER (...). Явная обработка NULL — то, что отличает аккуратного аналитика; на проверочных тестовых заданиях за это начисляют баллы.

Как выглядит running total в реальной задаче на собесе?

Типичная формулировка: «Есть таблица orders(order_date, amount). Построй выручку нарастающим итогом по месяцам и покажи, в каком месяце суммарная выручка впервые превысила 1 000 000».

Решение в два шага через CTE:

WITH monthly AS (
    SELECT
        date_trunc('month', order_date) AS month,
        SUM(amount) AS revenue
    FROM orders
    GROUP BY date_trunc('month', order_date)
)
SELECT
    month,
    revenue,
    SUM(revenue) OVER (
        ORDER BY month
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_revenue
FROM monthly
ORDER BY month;

Сначала агрегируем заказы до месячной выручки в CTE, потом считаем нарастающий итог по месяцам. Дальше можно обернуть это ещё одним уровнем и отфильтровать WHERE cumulative_revenue > 1000000, взяв первую строку. Такая двухслойная конструкция — «сначала GROUP BY, потом оконка поверх» — встречается на middle-собесах постоянно. Прорешать подобные задачи вживую можно в SQL-тренажёре с настоящим PostgreSQL в браузере.

Краткая шпаргалка по running total

Где потренироваться?

Running total запоминается только руками. Откройте пару наборов данных и прогоните накопительную сумму, скользящее среднее и расчёт по партициям подряд:

Потренируйтесь бесплатно: решите 10-15 задач на оконные функции — и запрос «покажи выручку нарастающим итогом» вы будете писать на автомате, вместе с разницей ROWS и RANGE и расчётом по группам.

Потренируй оконные функции
SUM() OVER, скользящие окна и нарастающий итог на живых данных. 5 задач без регистрации.
Открыть SQL-тренажёр →