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

Карьерник — квиз-тренажёр в Telegram с 1500+ вопросами для собесов аналитика. SQL, Python, A/B, метрики. Бесплатно.

Что такое 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;

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. Running total и running rate

Для когортного анализа — running retention:

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) AS activity_day,
        e.event_at - c.signup_day::TIMESTAMP AS days_since_signup
    FROM cohort c
    JOIN events e ON e.user_id = c.user_id
)
SELECT
    EXTRACT(DAY FROM days_since_signup)::INT AS day_n,
    COUNT(DISTINCT user_id) AS active_at_day_n
FROM activity
GROUP BY day_n
ORDER BY day_n;

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

Ошибка 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 или переменные.


Тренируйте SQL — откройте тренажёр с 1500+ вопросами для собесов.