Как посчитать накопительный итог (running total) в SQL

Проверь себя · 1/3разбор после ответа
В таблицах orders и users есть одноимённые столбцы: user_id, updated_at, status. Аналитик пишет SELECT * FROM orders NATURAL JOIN users. Что произойдёт?

Зачем нужен running total

Накопительный итог — одна из первых задач на оконные функции. Нужен для любого финансового отчёта («сколько выручки накопили с начала года»), когортного анализа («сколько пользователей зарегистрировалось к каждому дню»), продуктовых метрик («сколько всего покупок сделал пользователь к дате N»).

Это классический вопрос на собеседовании middle-аналитика: «напишите SQL для running total по дням». В оконных функциях это элегантно делается через SUM() OVER (ORDER BY day). В старых СУБД без window functions — через self-join с O(n²) сложностью.

В статье — все частые паттерны:

  • Базовый running total через SUM() OVER (ORDER BY)
  • Running total внутри группы (PARTITION BY category)
  • Скользящее окно (moving average) — ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  • Reset по месяцам через PARTITION BY
  • Cumulative users / running count unique
  • Running max / min для «рекордов» во времени
  • Running total в MySQL 5.x через self-join (для совместимости)

Что такое running total

Running total (накопительный итог, cumulative sum) — сумма всех значений до текущей строки включительно.

Пример:

day       | revenue | running_total
----------|---------|---------------
2026-04-01|    100  |    100
2026-04-02|    150  |    250
2026-04-03|    200  |    450
2026-04-04|    120  |    570

1. Базовый running total

SELECT
    day,
    revenue,
    SUM(revenue) OVER (ORDER BY day) AS running_total
FROM daily_revenue
ORDER BY day;

SUM() OVER (ORDER BY ...) без PARTITION BY — накапливает по всем строкам.

2. Running total по группам

Накопительный итог в каждой категории:

SELECT
    category,
    day,
    revenue,
    SUM(revenue) OVER (PARTITION BY category ORDER BY day) AS running_total
FROM daily_revenue_by_category
ORDER BY category, day;

Сбрасывается на каждую категорию.

3. Running total с явным окном (по умолчанию)

По умолчанию SUM() OVER (ORDER BY ...) использует:

ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW

Это и есть running total.

Явно:

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

4. Скользящее окно (moving average)

Среднее за последние 7 дней:

SELECT
    day,
    revenue,
    AVG(revenue) OVER (
        ORDER BY day
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS moving_avg_7d
FROM daily_revenue;

Включает текущий + 6 предыдущих = 7 дней.

5. Running total с reset каждый месяц

SELECT
    day,
    revenue,
    SUM(revenue) OVER (
        PARTITION BY DATE_TRUNC('month', day)
        ORDER BY day
    ) AS monthly_running_total
FROM daily_revenue
ORDER BY day;

Сбрасывается в 1-й день каждого месяца.

6. Cumulative users (когортный)

Накопительное число пользователей по датам регистрации:

WITH daily_signups AS (
    SELECT
        DATE(created_at) AS signup_day,
        COUNT(*) AS new_signups
    FROM users
    GROUP BY 1
)
SELECT
    signup_day,
    new_signups,
    SUM(new_signups) OVER (ORDER BY signup_day) AS total_users_to_date
FROM daily_signups
ORDER BY signup_day;
Закрепи формулу nakopitelnyj itog в Карьернике
Запомнить надолго — 5 коротких сессий с задачами на эту тему. Бесплатно
Тренировать nakopitelnyj itog в Telegram

7. Running total с процентом от итогового

WITH daily AS (
    SELECT day, revenue
    FROM daily_revenue
),
cumulative AS (
    SELECT
        day,
        revenue,
        SUM(revenue) OVER (ORDER BY day) AS running_total
    FROM daily
),
total AS (
    SELECT SUM(revenue) AS grand_total FROM daily
)
SELECT
    c.day,
    c.revenue,
    c.running_total,
    100.0 * c.running_total / t.grand_total AS pct_of_total
FROM cumulative c, total t
ORDER BY c.day;

8. Running count уникальных

Кол-во уникальных пользователей на каждый день:

-- это сложно через оконку, обычно через подзапрос
SELECT
    day,
    (SELECT COUNT(DISTINCT user_id)
     FROM events e2
     WHERE DATE(e2.event_at) <= days.day) AS cumulative_unique_users
FROM (SELECT DISTINCT DATE(event_at) AS day FROM events) days
ORDER BY day;

Медленно для больших данных. Альтернатива — HyperLogLog в современных DWH.

9. Running max / min

SELECT
    day,
    value,
    MAX(value) OVER (ORDER BY day) AS running_max,
    MIN(value) OVER (ORDER BY day) AS running_min
FROM daily_values;

Running max полезен для отслеживания «рекорда» по времени.

10. Running total через self-join (для СУБД без оконок)

MySQL 5.7 и старые версии без оконных функций:

SELECT
    a.day,
    a.revenue,
    SUM(b.revenue) AS running_total
FROM daily_revenue a
JOIN daily_revenue b ON b.day <= a.day
GROUP BY a.day, a.revenue
ORDER BY a.day;

Медленно (O(n²)) на больших таблицах, но работает везде.

11. Running total с условием (только paid)

SELECT
    day,
    revenue,
    SUM(CASE WHEN status = 'paid' THEN revenue ELSE 0 END)
        OVER (ORDER BY day) AS running_paid_revenue
FROM daily_data;

12. Накопительное удержание когорты

Пример: сколько уникальных пользователей за первые N дней после регистрации:

WITH cohort AS (
    SELECT user_id, MIN(DATE(event_at)) AS signup_day
    FROM events
    GROUP BY user_id
),
activity AS (
    SELECT
        c.user_id,
        c.signup_day,
        (DATE(e.event_at) - c.signup_day) AS days_since_signup
    FROM cohort c
    JOIN events e ON e.user_id = c.user_id
    WHERE e.event_at >= c.signup_day
),
per_day AS (
    SELECT
        days_since_signup,
        COUNT(DISTINCT user_id) AS active_at_day_n
    FROM activity
    GROUP BY days_since_signup
)
SELECT
    days_since_signup,
    active_at_day_n,
    SUM(active_at_day_n) OVER (ORDER BY days_since_signup) AS cumulative_active
FROM per_day
ORDER BY days_since_signup;

В Postgres вычитание date - date даёт целое число дней — здесь это как раз то, что нужно.

Частые ошибки

Ошибка 1. Забыть ORDER BY

-- без ORDER BY — непонятный результат
SUM(revenue) OVER (PARTITION BY category)

-- правильно
SUM(revenue) OVER (PARTITION BY category ORDER BY day)

Ошибка 2. Неожиданное окно с ORDER BY

Если есть ORDER BY без ROWSпо умолчанию SUM считает RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Это running total (что обычно и нужно).

Но для RANGE при одинаковых значениях в ORDER BY — ВСЕ они включаются. Если не хотите — используйте ROWS:

-- RANGE (default): tie rows объединяются
SUM(x) OVER (ORDER BY day)

-- ROWS: только до текущей строки
SUM(x) OVER (ORDER BY day ROWS UNBOUNDED PRECEDING)

Ошибка 3. Путать running и total

-- total (одна цифра для всех)
SUM(revenue) OVER ()

-- running (накопительный)
SUM(revenue) OVER (ORDER BY day)

Ошибка 4. Работа без PARTITION, когда нужна

Если хотите running per user — не забудьте PARTITION BY user_id.

Связанные темы

FAQ

Running total или cumulative sum — это одно и то же?

Да, синонимы.

Как добавить сброс по месяцам?

PARTITION BY DATE_TRUNC('month', day). Running total считается отдельно для каждого месяца.

Как сделать running average?

AVG(x) OVER (ORDER BY day). Аналогично SUM, но среднее вместо суммы.

Почему running total не работает в моем MySQL?

Оконные функции в MySQL 8+. В 5.7 и ниже — нет. Используйте self-join или переменные.