Нарастающий итог (Running Total, Cumulative Sum) — сумма всех предыдущих строк плюс текущая. Незаменим в финансовой аналитике, отчётах о росте, графиках накопленной выручки.
Базовый нарастающий итог
SELECT
sale_date,
revenue,
SUM(revenue) OVER (ORDER BY sale_date) AS running_total
FROM daily_sales;
| sale_date | revenue | running_total |
|---|---|---|
| 2026-01-01 | 10000 | 10000 |
| 2026-01-02 | 12000 | 22000 |
| 2026-01-03 | 9500 | 31500 |
| 2026-01-04 | 15000 | 46500 |
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;
| category | revenue | cumulative | cum_pct |
|---|---|---|---|
| Электроника | 500000 | 500000 | 55.6 |
| Мебель | 250000 | 750000 | 83.3 |
| Одежда | 150000 | 900000 | 100.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)
Нарастающий итог — основа любого финансового дашборда и отчёта о росте метрик. Освойте его — и большинство аналитических задач станут решаться в одном запросе.