SQLLab
Все статьи

Функция нарастающего итога (Running Total) в SQL: объясняем за 5 минут

Как считать нарастающий итог в SQL: SUM OVER с ROWS/RANGE, кумулятивная сумма по дням, скользящее среднее, нарастающие количества пользователей.

17 марта 2026 г.·4 мин чтения·

Нарастающий итог (Running Total, Cumulative Sum) — сумма всех предыдущих строк плюс текущая. Незаменим в финансовой аналитике, отчётах о росте, графиках накопленной выручки.

Базовый нарастающий итог

SELECT
    sale_date,
    revenue,
    SUM(revenue) OVER (ORDER BY sale_date) AS running_total
FROM daily_sales;
sale_daterevenuerunning_total
2026-01-011000010000
2026-01-021200022000
2026-01-03950031500
2026-01-041500046500

SUM(...) OVER (ORDER BY date) по умолчанию суммирует от начала до текущей строки включительно.


ROWS vs RANGE: важный нюанс

По умолчанию SUM() OVER (ORDER BY col) использует RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Это означает: включить все строки с одинаковым значением ORDER BY.

-- При одинаковых датах RANGE суммирует все одинаковые даты вместе
-- Это может давать «скачок» в нарастающем итоге

-- ROWS: строго по позиции (предсказуемо)
SUM(revenue) OVER (ORDER BY sale_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)

Если в одну дату несколько строк — используйте ROWS для предсказуемого поведения:

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

Нарастающий итог по группам (PARTITION BY)

-- Нарастающий итог по каждому менеджеру отдельно
SELECT
    manager_id,
    sale_date,
    revenue,
    SUM(revenue) OVER (
        PARTITION BY manager_id
        ORDER BY sale_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS manager_cumulative
FROM daily_sales
ORDER BY manager_id, sale_date;

Нарастающее количество пользователей

-- Сколько пользователей зарегистрировалось к каждому дню
WITH daily_regs AS (
    SELECT
        DATE_TRUNC('day', created_at)::date AS day,
        COUNT(*) AS new_users
    FROM users
    GROUP BY 1
)
SELECT
    day,
    new_users,
    SUM(new_users) OVER (ORDER BY day) AS total_users
FROM daily_regs
ORDER BY day;

Скользящее среднее (Moving Average)

-- Среднее за последние 7 дней (скользящее)
SELECT
    sale_date,
    revenue,
    ROUND(
        AVG(revenue) OVER (
            ORDER BY sale_date
            ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
        ), 2
    ) AS ma7
FROM daily_sales;

ROWS BETWEEN 6 PRECEDING AND CURRENT ROW — текущая строка + 6 предыдущих = 7 строк.

-- Скользящая сумма за 30 дней
SUM(revenue) OVER (
    ORDER BY sale_date
    ROWS BETWEEN 29 PRECEDING AND CURRENT ROW
)

Нарастающая доля (Running Percentage)

SELECT
    category,
    revenue,
    SUM(revenue) OVER (ORDER BY revenue DESC) AS cumulative,
    ROUND(
        SUM(revenue) OVER (ORDER BY revenue DESC)
        / SUM(revenue) OVER () * 100,
        1
    ) AS cum_pct
FROM category_sales
ORDER BY revenue DESC;
categoryrevenuecumulativecum_pct
Электроника50000050000055.6
Мебель25000075000083.3
Одежда150000900000100.0

Это ABC-анализ: какие категории дают 80% выручки.


Нарастающий итог за текущий месяц

SELECT
    created_at::date AS day,
    SUM(amount) AS daily_revenue,
    SUM(SUM(amount)) OVER (
        PARTITION BY DATE_TRUNC('month', created_at)
        ORDER BY created_at::date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS monthly_running_total
FROM orders
WHERE status = 'completed'
GROUP BY created_at::date
ORDER BY day;

SUM(SUM(amount)) — внешний SUM — оконная функция по результатам GROUP BY.


Нарастающий итог с CTE

Для читаемости разбиваем на шаги:

WITH daily AS (
    SELECT
        created_at::date AS day,
        COUNT(DISTINCT user_id) AS dau,
        SUM(amount) AS revenue
    FROM orders
    WHERE status = 'completed'
    GROUP BY 1
),
cumulative AS (
    SELECT
        day,
        dau,
        revenue,
        SUM(dau) OVER (ORDER BY day) AS total_active_users,
        SUM(revenue) OVER (ORDER BY day) AS total_revenue
    FROM daily
)
SELECT * FROM cumulative
ORDER BY day;

Нарастающий min/max

-- Исторический минимум и максимум цены актива
SELECT
    date,
    price,
    MIN(price) OVER (ORDER BY date ROWS UNBOUNDED PRECEDING) AS historical_min,
    MAX(price) OVER (ORDER BY date ROWS UNBOUNDED PRECEDING) AS historical_max
FROM asset_prices;

Производительность

Оконные функции с ROWS UNBOUNDED PRECEDING эффективны — PostgreSQL использует один проход по данным. Убедитесь, что есть индекс на ORDER BY поле:

CREATE INDEX idx_daily_sales_date ON daily_sales(sale_date);

Для больших таблиц с частыми запросами рассмотрите материализованный VIEW с нарастающим итогом, обновляемый раз в час/день.


Итог: синтаксис фреймов

-- Стандартный нарастающий итог
SUM(col) OVER (ORDER BY date)

-- Явный фрейм строк
SUM(col) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)

-- Скользящее окно N строк
AVG(col) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)

-- Симметричное окно
AVG(col) OVER (ORDER BY date ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING)

-- Весь раздел
SUM(col) OVER (PARTITION BY category)

Нарастающий итог — основа любого финансового дашборда и отчёта о росте метрик. Освойте его — и большинство аналитических задач станут решаться в одном запросе.

Похожие статьи

Попробуй на практике

Тренажёр с реальными задачами — бесплатно и без регистрации

Открыть тренажёр →