Коротко: нарастающий итог (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-01 | 100 | 100 |
| 2026-01-02 | 150 | 250 |
| 2026-01-03 | 80 | 330 |
| 2026-01-04 | 200 | 530 |
Третья колонка и есть нарастающий итог: 100, потом 100+150, потом 100+150+80 и так далее. Такие метрики нужны постоянно:
- Выручка нарастающим итогом с начала месяца или года (cumulative revenue).
- Накопленное число регистраций к каждой дате — кривая роста.
- Прогресс к плану: сколько набрали к текущему дню.
- Кумулятивный денежный поток в финансовых отчётах.
Как написать 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;
Разберём по частям:
SUM(revenue)— что суммируем.OVER (...)— превращает агрегат в оконную функцию: строки не схлопываются.ORDER BY sale_date— задаёт порядок накопления. Без него «нарастающий итог» не имеет смысла: непонятно, в каком направлении копить.ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW— рамка окна: «от самой первой строки до текущей».
UNBOUNDED PRECEDING означает «без нижней границы» — то есть с начала набора. CURRENT ROW — текущая строка. Вместе они дают накопление от старта до здесь.
Нужно ли всегда писать ROWS UNBOUNDED PRECEDING?
Технически нет, но писать рамку явно — хорошая привычка. Если опустить ROWS BETWEEN ..., SQL применит рамку по умолчанию — RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. В большинстве случаев результат совпадёт, но есть ловушка с дубликатами в колонке сортировки.
Разница между ROWS и RANGE:
ROWSсчитает физические строки: текущая строка и все предыдущие по порядку.RANGEсчитает по значениям: все строки с тем же значениемORDER BY, что и текущая, попадают в одну «ступеньку».
Если в один день несколько продаж и вы сортируете по дате, 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)?
Это два разных, но похожих по форме окна. Разница — в нижней границе рамки.
- Running total — рамка
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Копит всё от начала. - Скользящее окно (moving / rolling) — рамка фиксированной ширины, например
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW. Учитывает только последние N строк.
Скользящее среднее за 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 PRECEDING | N PRECEDING |
| Что считает | всё с начала | только последние N |
| Типичная функция | SUM | AVG (сглаживание тренда) |
| Применение | 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
- Базовый нарастающий итог:
SUM(x) OVER (ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). - По группам: добавьте
PARTITION BY group_colпередORDER BY. - Скользящее окно: замените нижнюю границу на
N PRECEDING. - Всегда указывайте рамку
ROWSявно — избегаете сюрпризов с дубликатами дат. - Не используйте self-join — оконная функция быстрее и читается лучше.
- Пропуски дат закрывайте календарём и
COALESCE, NULL — черезCOALESCE(x, 0)при необходимости.
Где потренироваться?
Running total запоминается только руками. Откройте пару наборов данных и прогоните накопительную сумму, скользящее среднее и расчёт по партициям подряд:
- SQL-тренажёр — задачи на оконные функции с автопроверкой и настоящим PostgreSQL прямо в браузере.
- Раздел оконных функций — тематическая подборка именно по
OVER, рамкам и партициям. - Курс «SQL с нуля» — пошаговая программа, где оконным функциям и нарастающему итогу посвящён отдельный урок.
- AI мок-интервью — прогоните вопрос про running total в формате реального собеса и получите разбор ответа.
Потренируйтесь бесплатно: решите 10-15 задач на оконные функции — и запрос «покажи выручку нарастающим итогом» вы будете писать на автомате, вместе с разницей ROWS и RANGE и расчётом по группам.