Dieser Artikel ist derzeit auf Russisch — die englische Übersetzung ist in Arbeit.
Обычный GROUP BY считает агрегаты на одном уровне.
Например, можно посчитать выручку по странам:
SELECT
country,
SUM(amount) AS revenue
FROM orders
GROUP BY country;
Результат может быть таким:
country | revenue
--------+--------
Germany | 2300
Vietnam | 5000
Spain | 900
Можно посчитать выручку по странам и статусам заказов:
SELECT
country,
status,
SUM(amount) AS revenue
FROM orders
GROUP BY country, status;
Результат:
country | status | revenue
--------+----------+--------
Germany | paid | 2300
Vietnam | paid | 5000
Vietnam | refunded | 900
Spain | failed | 700
Но в отчётах часто нужно не только это.
Обычно хочется увидеть:
- детализацию по стране и статусу;
- подытог по каждой стране;
- общий итог по всему отчёту.
Например:
country | status | revenue
--------+--------------+--------
Germany | paid | 2300
Germany | ALL STATUSES | 2300
Vietnam | paid | 5000
Vietnam | refunded | 900
Vietnam | ALL STATUSES | 5900
Spain | failed | 700
Spain | ALL STATUSES | 700
ALL | ALL | 8900
Такой отчёт можно собрать несколькими запросами через UNION ALL.
Но в SQL есть специальный инструмент для таких задач — ROLLUP.
Он позволяет добавить подытоги и общий итог прямо в GROUP BY.
Что такое ROLLUP простыми словами
ROLLUP — это расширение GROUP BY, которое строит агрегаты на нескольких уровнях детализации.
Например:
GROUP BY ROLLUP (country, status)
означает:
1. Сначала сгруппируй по country и status.
2. Потом сделай подытог по country.
3. Потом сделай общий итог по всему набору.
То есть ROLLUP (country, status) создаёт такие уровни:
(country, status)
(country)
()
Пустые скобки () означают общий итог без группировки по колонкам.
Если объяснить совсем просто:
ROLLUP берёт обычный отчёт и добавляет к нему подытоги снизу вверх.
Базовый пример ROLLUP
Допустим, есть таблица orders:
id | country | status | amount
---+---------+----------+--------
1 | Vietnam | paid | 1500
2 | Vietnam | paid | 2300
3 | Vietnam | refunded | 900
4 | Germany | paid | 2000
5 | Spain | failed | 700
Хотим посчитать выручку:
- по стране и статусу;
- по стране целиком;
- общий итог.
Запрос:
SELECT
country,
status,
SUM(amount) AS revenue
FROM orders
GROUP BY ROLLUP (country, status);
Результат может быть таким:
country | status | revenue
--------+----------+--------
Germany | paid | 2000
Germany | NULL | 2000
Spain | failed | 700
Spain | NULL | 700
Vietnam | paid | 3800
Vietnam | refunded | 900
Vietnam | NULL | 4700
NULL | NULL | 7400
Что здесь происходит?
Строки, где заполнены и country, и status, — это обычная детализация.
Vietnam | paid | 3800
Vietnam | refunded | 900
Строка, где country заполнен, а status = NULL, — это подытог по стране.
Vietnam | NULL | 4700
Строка, где и country, и status равны NULL, — это общий итог.
NULL | NULL | 7400
На уровне механики всё работает.
Но для реального отчёта такой результат пока опасен: непонятно, где настоящий NULL, а где подытог.
Почему в итоговых строках появляются NULL
Когда ROLLUP считает подытог, часть колонок перестаёт участвовать в группировке.
Например, для строки:
Vietnam | NULL | 4700
это не значит, что у заказов статус реально NULL.
Это значит:
это итог по всем статусам внутри Vietnam.
То есть status здесь свернут.
А строка:
NULL | NULL | 7400
означает:
это общий итог по всем странам и всем статусам.
Проблема в том, что NULL может быть и настоящим значением в данных.
Например, в таблице действительно может быть заказ без статуса:
country | status | amount
--------+--------+--------
Vietnam | NULL | 500
Тогда в результате появится строка с status = NULL, и глазами будет сложно понять:
- это заказ без статуса;
- или подытог по всем статусам.
Именно поэтому с ROLLUP почти всегда нужно использовать функцию GROUPING().
GROUPING(): как отличить подытог от настоящего NULL
Функция GROUPING(column) показывает, была ли колонка свернута на текущем уровне агрегации.
Она возвращает:
0 — колонка участвует в группировке
1 — колонка свернута, то есть это итоговый уровень
Пример:
SELECT
GROUPING(country) AS g_country,
GROUPING(status) AS g_status,
country,
status,
SUM(amount) AS revenue
FROM orders
GROUP BY ROLLUP (country, status)
ORDER BY
GROUPING(country),
country,
GROUPING(status),
status;
Результат может выглядеть так:
g_country | g_status | country | status | revenue
----------+----------+---------+----------+--------
0 | 0 | Germany | paid | 2000
0 | 1 | Germany | NULL | 2000
0 | 0 | Spain | failed | 700
0 | 1 | Spain | NULL | 700
0 | 0 | Vietnam | paid | 3800
0 | 0 | Vietnam | refunded | 900
0 | 1 | Vietnam | NULL | 4700
1 | 1 | NULL | NULL | 7400
Теперь всё понятно.
Если:
GROUPING(status) = 1
значит status не участвует в текущем уровне. Это подытог по всем статусам.
Если:
GROUPING(status) = 0
значит status участвует в группировке. Если при этом сам status равен NULL, значит это настоящий NULL из данных.
Главное правило:
GROUPING(col) = 1 не означает «в колонке NULL».
Это означает «колонка свернута в итоговой строке».
Это очень важное различие.
Делаем отчёт читаемым: подписи вместо NULL
В реальном отчёте лучше сразу заменить итоговые NULL на понятные подписи.
Например:
SELECT
CASE
WHEN GROUPING(country) = 1 THEN 'ALL COUNTRIES'
WHEN country IS NULL THEN 'NO COUNTRY'
ELSE country
END AS country_label,
CASE
WHEN GROUPING(status) = 1 THEN 'ALL STATUSES'
WHEN status IS NULL THEN 'NO STATUS'
ELSE status
END AS status_label,
SUM(amount) AS revenue
FROM orders
GROUP BY ROLLUP (country, status)
ORDER BY
GROUPING(country),
country_label,
GROUPING(status),
status_label;
Результат:
country_label | status_label | revenue
--------------+--------------+--------
Germany | paid | 2000
Germany | ALL STATUSES | 2000
Spain | failed | 700
Spain | ALL STATUSES | 700
Vietnam | paid | 3800
Vietnam | refunded | 900
Vietnam | ALL STATUSES | 4700
ALL COUNTRIES | ALL STATUSES | 7400
Теперь результат можно отдавать в BI, CSV или API без объяснения, что «пустой статус иногда значит все статусы».
Также мы отдельно обработали реальные NULL:
WHEN status IS NULL THEN 'NO STATUS'
Это важно: настоящий пропуск в данных и итоговая строка — разные вещи.
Порядок колонок в ROLLUP важен
ROLLUP сворачивает уровни справа налево.
Например:
ROLLUP (country, status)
создаёт уровни:
(country, status)
(country)
()
То есть:
- детализация по стране и статусу;
- подытог по стране;
- общий итог.
А вот:
ROLLUP (status, country)
создаёт другие уровни:
(status, country)
(status)
()
Теперь подытог будет по статусу, а не по стране.
Это уже другой отчёт.
Первый вариант отвечает на вопрос:
сколько выручки по статусам внутри каждой страны?
Второй вариант отвечает на вопрос:
сколько выручки по странам внутри каждого статуса?
Поэтому порядок колонок в ROLLUP — это не косметика. Он задаёт иерархию отчёта.
Можно запомнить так:
В ROLLUP (a, b, c) детализация идёт слева направо, а сворачивание — справа налево.
Для трёх колонок:
ROLLUP (year, month, day)
создаст:
(year, month, day)
(year, month)
(year)
()
Это естественная иерархия:
год → месяц → день
ROLLUP для отчёта «месяц → страна»
Теперь разберём более реальный пример.
Есть таблица orders и таблица users.
orders:
id | user_id | amount | status | created_at
---+---------+--------+--------+---------------------
1 | 1 | 1500 | paid | 2026-06-01 10:00:00
2 | 2 | 2300 | paid | 2026-06-05 12:00:00
3 | 3 | 900 | paid | 2026-07-02 09:00:00
users:
id | country
---+---------
1 | Vietnam
2 | Germany
3 | Vietnam
Хотим отчёт по оплаченной выручке:
- по месяцу и стране;
- подытог по месяцу;
- общий итог за весь период.
Запрос:
SELECT
CASE
WHEN GROUPING(DATE_TRUNC('month', o.created_at)) = 1 THEN 'ALL MONTHS'
ELSE TO_CHAR(DATE_TRUNC('month', o.created_at), 'YYYY-MM')
END AS month_label,
CASE
WHEN GROUPING(u.country) = 1 THEN 'ALL COUNTRIES'
WHEN u.country IS NULL THEN 'NO COUNTRY'
ELSE u.country
END AS country_label,
SUM(o.amount) AS revenue
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'paid'
GROUP BY ROLLUP (
DATE_TRUNC('month', o.created_at),
u.country
)
ORDER BY
GROUPING(DATE_TRUNC('month', o.created_at)),
DATE_TRUNC('month', o.created_at),
GROUPING(u.country),
u.country;
Результат может быть таким:
month_label | country_label | revenue
------------+---------------+--------
2026-06 | Germany | 2300
2026-06 | Vietnam | 1500
2026-06 | ALL COUNTRIES | 3800
2026-07 | Vietnam | 900
2026-07 | ALL COUNTRIES | 900
ALL MONTHS | ALL COUNTRIES | 4700
Здесь ROLLUP строит уровни:
(month, country)
(month)
()
То есть:
- страны внутри месяца;
- итог по каждому месяцу;
- общий итог.
Это классический drill-down отчёт.
Важно: выражение в SELECT и GROUP BY должно совпадать
В примере выше мы группируем не просто по колонке, а по выражению:
DATE_TRUNC('month', o.created_at)
Если вы используете выражение в GROUP BY, то в SELECT и GROUPING() нужно ссылаться на то же самое выражение.
Например:
GROUPING(DATE_TRUNC('month', o.created_at))
а не:
GROUPING(o.created_at)
Почему?
Потому что уровень группировки построен именно по месяцу, а не по исходному timestamp.
Без этого можно получить ошибку или неправильную логику отчёта.
Для читаемости в сложных запросах можно вынести выражения в CTE.
WITH prepared_orders AS (
SELECT
o.id,
o.amount,
DATE_TRUNC('month', o.created_at) AS order_month,
u.country
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'paid'
)
SELECT
CASE
WHEN GROUPING(order_month) = 1 THEN 'ALL MONTHS'
ELSE TO_CHAR(order_month, 'YYYY-MM')
END AS month_label,
CASE
WHEN GROUPING(country) = 1 THEN 'ALL COUNTRIES'
WHEN country IS NULL THEN 'NO COUNTRY'
ELSE country
END AS country_label,
SUM(amount) AS revenue
FROM prepared_orders
GROUP BY ROLLUP (order_month, country)
ORDER BY
GROUPING(order_month),
order_month,
GROUPING(country),
country;
Так запрос становится проще читать: мы сначала готовим поля, а потом строим отчёт.
Стабильная сортировка итогов
Без ORDER BY порядок строк в SQL не гарантирован.
Это особенно важно для ROLLUP, потому что в результате смешиваются:
- детальные строки;
- подытоги;
- общий итог.
Если не задать порядок явно, база может вернуть строки в удобном для себя порядке.
Для отчёта это плохо. Сегодня подытог может оказаться под деталями, завтра — выше них.
Поэтому сортировку лучше строить через GROUPING().
Например:
ORDER BY
GROUPING(country),
country,
GROUPING(status),
status;
Идея такая:
- сначала строки, где
country участвует в группировке;
- потом общий итог по странам;
- внутри страны сначала детальные статусы;
- потом итог по всем статусам.
Для человекочитаемых отчётов часто нужно, чтобы подытог шёл после деталей.
Если нужна более гибкая сортировка, можно добавить техническую колонку:
CASE
WHEN GROUPING(country) = 0 AND GROUPING(status) = 0 THEN 1
WHEN GROUPING(country) = 0 AND GROUPING(status) = 1 THEN 2
WHEN GROUPING(country) = 1 AND GROUPING(status) = 1 THEN 3
END AS row_level_sort
И потом сортировать по ней.
Добавляем row_level: уровень строки
Для API или BI часто полезно явно указать уровень строки.
Например:
SELECT
CASE
WHEN GROUPING(country) = 0 AND GROUPING(status) = 0 THEN 'detail'
WHEN GROUPING(country) = 0 AND GROUPING(status) = 1 THEN 'country_total'
WHEN GROUPING(country) = 1 AND GROUPING(status) = 1 THEN 'grand_total'
END AS row_level,
CASE
WHEN GROUPING(country) = 1 THEN 'ALL COUNTRIES'
WHEN country IS NULL THEN 'NO COUNTRY'
ELSE country
END AS country_label,
CASE
WHEN GROUPING(status) = 1 THEN 'ALL STATUSES'
WHEN status IS NULL THEN 'NO STATUS'
ELSE status
END AS status_label,
SUM(amount) AS revenue
FROM orders
GROUP BY ROLLUP (country, status)
ORDER BY
CASE
WHEN GROUPING(country) = 0 AND GROUPING(status) = 0 THEN 1
WHEN GROUPING(country) = 0 AND GROUPING(status) = 1 THEN 2
WHEN GROUPING(country) = 1 AND GROUPING(status) = 1 THEN 3
END,
country_label,
status_label;
Результат:
row_level | country_label | status_label | revenue
--------------+---------------+--------------+--------
detail | Germany | paid | 2000
detail | Spain | failed | 700
detail | Vietnam | paid | 3800
detail | Vietnam | refunded | 900
country_total | Germany | ALL STATUSES | 2000
country_total | Spain | ALL STATUSES | 700
country_total | Vietnam | ALL STATUSES | 4700
grand_total | ALL COUNTRIES | ALL STATUSES | 7400
Теперь downstream-коду не нужно угадывать, какая строка является подытогом.
Это хороший паттерн для регулярных отчётов.
ROLLUP — это сокращение для GROUPING SETS
ROLLUP не отдельная магия. Это короткая запись для GROUPING SETS.
Например:
GROUP BY ROLLUP (country, status)
эквивалентно:
GROUP BY GROUPING SETS (
(country, status),
(country),
()
)
То есть ROLLUP просто автоматически строит иерархию уровней.
Для трёх колонок:
GROUP BY ROLLUP (year, month, day)
это то же самое, что:
GROUP BY GROUPING SETS (
(year, month, day),
(year, month),
(year),
()
)
Если вам нужна именно такая иерархия, ROLLUP короче и удобнее.
Если же нужны нестандартные уровни, лучше использовать GROUPING SETS.
Когда использовать ROLLUP, а когда GROUPING SETS
ROLLUP хорош для естественных иерархий.
Например:
год → месяц → день
страна → регион → город
категория → подкатегория → товар
отдел → менеджер → сотрудник
В таких случаях подытоги имеют понятный смысл.
Например:
GROUP BY ROLLUP (country, city)
даёт:
(country, city)
(country)
()
То есть города внутри страны, итог по стране и общий итог.
А вот если измерения независимы, ROLLUP может быть не тем, что нужно.
Например:
country
payment_method
Страна и способ оплаты не всегда образуют естественную иерархию. Если написать:
GROUP BY ROLLUP (country, payment_method)
вы получите:
(country, payment_method)
(country)
()
То есть будет итог по стране, но не будет отдельного итога по способу оплаты.
Если вам нужны и итоги по странам, и итоги по способам оплаты, и общий итог, лучше использовать GROUPING SETS или CUBE.
Например:
GROUP BY GROUPING SETS (
(country, payment_method),
(country),
(payment_method),
()
)
Или:
GROUP BY CUBE (country, payment_method)
если нужны все комбинации.
ROLLUP против CUBE
ROLLUP строит иерархию.
ROLLUP (a, b)
создаёт:
(a, b)
(a)
()
CUBE строит все комбинации.
CUBE (a, b)
создаёт:
(a, b)
(a)
(b)
()
Разница важная.
Если у вас отчёт:
страна → город
логичнее ROLLUP.
Если у вас независимые измерения:
страна и статус оплаты
и нужны итоги по каждому измерению отдельно, может подойти CUBE.
Можно запомнить так:
ROLLUP — иерархия с подытогами.
CUBE — все комбинации измерений.
GROUPING SETS — вручную выбранные уровни.
ROLLUP с HAVING
HAVING можно использовать вместе с ROLLUP.
Например, оставить только строки, где выручка больше 1000:
SELECT
country,
status,
SUM(amount) AS revenue
FROM orders
GROUP BY ROLLUP (country, status)
HAVING SUM(amount) > 1000;
Но здесь нужно быть осторожным.
HAVING применится ко всем уровням:
- к детальным строкам;
- к подытогам;
- к общему итогу.
Иногда это именно то, что нужно.
А иногда вы хотите фильтровать только детальные строки, но оставить все подытоги.
Тогда используйте GROUPING().
Например:
SELECT
country,
status,
SUM(amount) AS revenue
FROM orders
GROUP BY ROLLUP (country, status)
HAVING
GROUPING(country) = 1
OR GROUPING(status) = 1
OR SUM(amount) > 1000;
Этот запрос оставит итоговые строки и детальные строки с выручкой больше 1000.
Но такие условия быстро становятся сложными. Для регулярных отчётов лучше явно добавлять row_level и аккуратно проектировать фильтрацию.
ROLLUP и WHERE
Обычный WHERE применяется до группировки.
Например:
SELECT
country,
status,
SUM(amount) AS revenue
FROM orders
WHERE created_at >= DATE '2026-06-01'
AND created_at < DATE '2026-07-01'
GROUP BY ROLLUP (country, status);
Здесь сначала выбираются только заказы за июнь 2026 года.
А потом по этим строкам строятся:
- детали по стране и статусу;
- подытоги по стране;
- общий итог за июнь.
Это удобно: фильтр по периоду пишется один раз и применяется ко всем уровням отчёта.
Если бы вы делали отчёт через несколько UNION ALL, этот фильтр пришлось бы повторять в каждой ветке.
Почему ROLLUP лучше делать в SQL, а не в приложении
Иногда кажется, что можно просто получить детальные строки, а подытоги посчитать уже в приложении.
Например:
SELECT country, status, SUM(amount)
FROM orders
GROUP BY country, status;
А потом в коде сложить суммы по странам и общий итог.
Так можно, но у этого подхода есть минусы:
- логика отчёта размазывается между SQL и приложением;
- сложнее проверять корректность;
- легко получить расхождения между BI и API;
- приложение может считать подытоги иначе, чем база;
- сложнее переиспользовать запрос;
- больше данных приходится передавать наружу.
ROLLUP позволяет держать логику отчёта в SQL.
База сама знает, как агрегировать данные, и возвращает готовый результат с деталями, подытогами и общим итогом.
Производительность ROLLUP
ROLLUP не означает, что база «бесплатно» добавляет итоги.
Ей всё равно нужно выполнить агрегацию на нескольких уровнях.
Но обычно это лучше, чем писать несколько отдельных запросов с одинаковыми WHERE, JOIN и GROUP BY, а потом склеивать их через UNION ALL.
Например, вместо:
SELECT country, status, SUM(amount)
FROM orders
GROUP BY country, status
UNION ALL
SELECT country, NULL, SUM(amount)
FROM orders
GROUP BY country
UNION ALL
SELECT NULL, NULL, SUM(amount)
FROM orders;
можно написать:
SELECT
country,
status,
SUM(amount)
FROM orders
GROUP BY ROLLUP (country, status);
Так запрос короче, а планировщик может эффективнее работать с одной логикой агрегации.
Но на больших таблицах всё равно стоит проверять план через:
EXPLAIN
или:
EXPLAIN ANALYZE
Особенно если есть сложные JOIN, фильтры, много уровней или тяжёлые агрегаты.
ROLLUP в PostgreSQL
В PostgreSQL можно писать стандартный синтаксис:
GROUP BY ROLLUP (country, status)
Также доступны:
GROUPING SETS
CUBE
GROUPING()
Это делает PostgreSQL удобным для отчётных запросов с несколькими уровнями агрегации.
Пример:
SELECT
country,
status,
SUM(amount) AS revenue,
GROUPING(country) AS g_country,
GROUPING(status) AS g_status
FROM orders
GROUP BY ROLLUP (country, status);
Функция GROUPING() особенно важна, если в данных могут быть настоящие NULL.
ROLLUP в MySQL
В MySQL используется другой синтаксис:
GROUP BY country, status WITH ROLLUP
Пример:
SELECT
country,
status,
SUM(amount) AS revenue
FROM orders
GROUP BY country, status WITH ROLLUP;
Идея похожая: сначала детализация по country, status, потом подытоги и общий итог.
Но возможности отличаются от PostgreSQL.
В MySQL нет такой же стандартной формы GROUP BY ROLLUP (...), и поддержка функций вроде GROUPING() зависит от версии.
Также с сортировкой рядом с WITH ROLLUP нужно быть внимательнее: лучше проверять поведение на конкретной версии MySQL и не полагаться на случайный порядок строк.
ROLLUP в ClickHouse
В ClickHouse тоже есть инструменты для итогов и подытогов, но синтаксис и поведение могут отличаться.
Можно встретить варианты вроде:
GROUP BY country, status WITH ROLLUP
Также в ClickHouse есть WITH CUBE и WITH TOTALS.
Для аналитических отчётов это полезно, но важно помнить:
- порядок строк без явного
ORDER BY не гарантирован;
- поведение итоговых строк может отличаться от PostgreSQL;
- конкретные возможности зависят от версии и настроек.
Если вы переносите запросы между PostgreSQL, MySQL и ClickHouse, проверяйте синтаксис и результат на реальных данных.
Практические шаблоны
Подытоги по стране и общий итог
SELECT
country,
status,
SUM(amount) AS revenue
FROM orders
GROUP BY ROLLUP (country, status);
То же самое с флагами GROUPING
SELECT
GROUPING(country) AS g_country,
GROUPING(status) AS g_status,
country,
status,
SUM(amount) AS revenue
FROM orders
GROUP BY ROLLUP (country, status);
Читаемые подписи итогов
SELECT
CASE
WHEN GROUPING(country) = 1 THEN 'ALL COUNTRIES'
WHEN country IS NULL THEN 'NO COUNTRY'
ELSE country
END AS country_label,
CASE
WHEN GROUPING(status) = 1 THEN 'ALL STATUSES'
WHEN status IS NULL THEN 'NO STATUS'
ELSE status
END AS status_label,
SUM(amount) AS revenue
FROM orders
GROUP BY ROLLUP (country, status);
Уровень строки для API или BI
SELECT
CASE
WHEN GROUPING(country) = 0 AND GROUPING(status) = 0 THEN 'detail'
WHEN GROUPING(country) = 0 AND GROUPING(status) = 1 THEN 'country_total'
WHEN GROUPING(country) = 1 AND GROUPING(status) = 1 THEN 'grand_total'
END AS row_level,
country,
status,
SUM(amount) AS revenue
FROM orders
GROUP BY ROLLUP (country, status);
Отчёт по месяцам и странам
WITH prepared_orders AS (
SELECT
DATE_TRUNC('month', o.created_at) AS order_month,
u.country,
o.amount
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'paid'
)
SELECT
CASE
WHEN GROUPING(order_month) = 1 THEN 'ALL MONTHS'
ELSE TO_CHAR(order_month, 'YYYY-MM')
END AS month_label,
CASE
WHEN GROUPING(country) = 1 THEN 'ALL COUNTRIES'
WHEN country IS NULL THEN 'NO COUNTRY'
ELSE country
END AS country_label,
SUM(amount) AS revenue
FROM prepared_orders
GROUP BY ROLLUP (order_month, country)
ORDER BY
GROUPING(order_month),
order_month,
GROUPING(country),
country;
Иерархия год → месяц → день
SELECT
EXTRACT(YEAR FROM created_at) AS year,
EXTRACT(MONTH FROM created_at) AS month,
EXTRACT(DAY FROM created_at) AS day,
SUM(amount) AS revenue
FROM orders
GROUP BY ROLLUP (
EXTRACT(YEAR FROM created_at),
EXTRACT(MONTH FROM created_at),
EXTRACT(DAY FROM created_at)
);
ROLLUP как GROUPING SETS
SELECT
country,
status,
SUM(amount) AS revenue
FROM orders
GROUP BY GROUPING SETS (
(country, status),
(country),
()
);
Что важно запомнить
ROLLUP добавляет к обычной группировке подытоги и общий итог.
Пример:
SELECT
country,
status,
SUM(amount) AS revenue
FROM orders
GROUP BY ROLLUP (country, status);
ROLLUP (country, status) создаёт уровни:
(country, status)
(country)
()
То есть:
детализация по стране и статусу
подытог по стране
общий итог
Главные правила:
- порядок колонок в
ROLLUP важен;
- сворачивание идёт справа налево;
- для
n колонок создаётся n + 1 уровень агрегации;
- итоговые строки получают
NULL в свернутых колонках;
- настоящий
NULL из данных и итоговый NULL нужно различать через GROUPING();
GROUPING(col) = 1 означает, что колонка свернута;
- для отчётов лучше добавлять понятные подписи и
row_level;
- порядок строк без
ORDER BY не гарантирован;
ROLLUP — это сокращение для частого случая GROUPING SETS.
Короткий вывод
ROLLUP нужен, когда отчёт строится по естественной иерархии и требует подытогов.
Например:
страна → статус → выручка
год → месяц → день → сумма
категория → подкатегория → продажи
отдел → менеджер → зарплатный фонд
Вместо нескольких запросов с UNION ALL можно написать один:
SELECT
country,
status,
SUM(amount) AS revenue
FROM orders
GROUP BY ROLLUP (country, status);
Главная мысль:
ROLLUP делает детальные строки, подытоги и общий итог внутри одного GROUP BY.
Но хороший ROLLUP-отчёт — это не только короткий синтаксис.
Нужно заранее продумать:
- какие уровни нужны;
- в каком порядке должны идти строки;
- как подписать подытоги;
- как отличить настоящий
NULL от итоговой строки;
- что должен получить downstream-код или BI-инструмент.
Если это сделать, ROLLUP превращает сложный отчёт с подытогами в понятный и поддерживаемый SQL-запрос.
Обычный
GROUP BYсчитает агрегаты на одном уровне.Например, можно посчитать выручку по странам:
SELECT country, SUM(amount) AS revenue FROM orders GROUP BY country;Результат может быть таким:
Можно посчитать выручку по странам и статусам заказов:
SELECT country, status, SUM(amount) AS revenue FROM orders GROUP BY country, status;Результат:
Но в отчётах часто нужно не только это.
Обычно хочется увидеть:
Например:
Такой отчёт можно собрать несколькими запросами через
UNION ALL.Но в SQL есть специальный инструмент для таких задач —
ROLLUP.Он позволяет добавить подытоги и общий итог прямо в
GROUP BY.Что такое ROLLUP простыми словами
ROLLUP— это расширениеGROUP BY, которое строит агрегаты на нескольких уровнях детализации.Например:
GROUP BY ROLLUP (country, status)означает:
То есть
ROLLUP (country, status)создаёт такие уровни:Пустые скобки
()означают общий итог без группировки по колонкам.Если объяснить совсем просто:
Базовый пример ROLLUP
Допустим, есть таблица
orders:Хотим посчитать выручку:
Запрос:
SELECT country, status, SUM(amount) AS revenue FROM orders GROUP BY ROLLUP (country, status);Результат может быть таким:
Что здесь происходит?
Строки, где заполнены и
country, иstatus, — это обычная детализация.Строка, где
countryзаполнен, аstatus = NULL, — это подытог по стране.Строка, где и
country, иstatusравныNULL, — это общий итог.На уровне механики всё работает. Но для реального отчёта такой результат пока опасен: непонятно, где настоящий
NULL, а где подытог.Почему в итоговых строках появляются NULL
Когда
ROLLUPсчитает подытог, часть колонок перестаёт участвовать в группировке.Например, для строки:
это не значит, что у заказов статус реально
NULL.Это значит:
То есть
statusздесь свернут.А строка:
означает:
Проблема в том, что
NULLможет быть и настоящим значением в данных.Например, в таблице действительно может быть заказ без статуса:
Тогда в результате появится строка с
status = NULL, и глазами будет сложно понять:Именно поэтому с
ROLLUPпочти всегда нужно использовать функциюGROUPING().GROUPING(): как отличить подытог от настоящего NULL
Функция
GROUPING(column)показывает, была ли колонка свернута на текущем уровне агрегации.Она возвращает:
Пример:
SELECT GROUPING(country) AS g_country, GROUPING(status) AS g_status, country, status, SUM(amount) AS revenue FROM orders GROUP BY ROLLUP (country, status) ORDER BY GROUPING(country), country, GROUPING(status), status;Результат может выглядеть так:
Теперь всё понятно.
Если:
значит
statusне участвует в текущем уровне. Это подытог по всем статусам.Если:
значит
statusучаствует в группировке. Если при этом самstatusравенNULL, значит это настоящийNULLиз данных.Главное правило:
Это очень важное различие.
Делаем отчёт читаемым: подписи вместо NULL
В реальном отчёте лучше сразу заменить итоговые
NULLна понятные подписи.Например:
SELECT CASE WHEN GROUPING(country) = 1 THEN 'ALL COUNTRIES' WHEN country IS NULL THEN 'NO COUNTRY' ELSE country END AS country_label, CASE WHEN GROUPING(status) = 1 THEN 'ALL STATUSES' WHEN status IS NULL THEN 'NO STATUS' ELSE status END AS status_label, SUM(amount) AS revenue FROM orders GROUP BY ROLLUP (country, status) ORDER BY GROUPING(country), country_label, GROUPING(status), status_label;Результат:
Теперь результат можно отдавать в BI, CSV или API без объяснения, что «пустой статус иногда значит все статусы».
Также мы отдельно обработали реальные
NULL:WHEN status IS NULL THEN 'NO STATUS'Это важно: настоящий пропуск в данных и итоговая строка — разные вещи.
Порядок колонок в ROLLUP важен
ROLLUPсворачивает уровни справа налево.Например:
ROLLUP (country, status)создаёт уровни:
То есть:
А вот:
ROLLUP (status, country)создаёт другие уровни:
Теперь подытог будет по статусу, а не по стране.
Это уже другой отчёт.
Первый вариант отвечает на вопрос:
Второй вариант отвечает на вопрос:
Поэтому порядок колонок в
ROLLUP— это не косметика. Он задаёт иерархию отчёта.Можно запомнить так:
Для трёх колонок:
ROLLUP (year, month, day)создаст:
Это естественная иерархия:
ROLLUP для отчёта «месяц → страна»
Теперь разберём более реальный пример.
Есть таблица
ordersи таблицаusers.orders:users:Хотим отчёт по оплаченной выручке:
Запрос:
SELECT CASE WHEN GROUPING(DATE_TRUNC('month', o.created_at)) = 1 THEN 'ALL MONTHS' ELSE TO_CHAR(DATE_TRUNC('month', o.created_at), 'YYYY-MM') END AS month_label, CASE WHEN GROUPING(u.country) = 1 THEN 'ALL COUNTRIES' WHEN u.country IS NULL THEN 'NO COUNTRY' ELSE u.country END AS country_label, SUM(o.amount) AS revenue FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status = 'paid' GROUP BY ROLLUP ( DATE_TRUNC('month', o.created_at), u.country ) ORDER BY GROUPING(DATE_TRUNC('month', o.created_at)), DATE_TRUNC('month', o.created_at), GROUPING(u.country), u.country;Результат может быть таким:
Здесь
ROLLUPстроит уровни:То есть:
Это классический drill-down отчёт.
Важно: выражение в SELECT и GROUP BY должно совпадать
В примере выше мы группируем не просто по колонке, а по выражению:
DATE_TRUNC('month', o.created_at)Если вы используете выражение в
GROUP BY, то вSELECTиGROUPING()нужно ссылаться на то же самое выражение.Например:
GROUPING(DATE_TRUNC('month', o.created_at))а не:
GROUPING(o.created_at)Почему?
Потому что уровень группировки построен именно по месяцу, а не по исходному timestamp.
Без этого можно получить ошибку или неправильную логику отчёта.
Для читаемости в сложных запросах можно вынести выражения в CTE.
WITH prepared_orders AS ( SELECT o.id, o.amount, DATE_TRUNC('month', o.created_at) AS order_month, u.country FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status = 'paid' ) SELECT CASE WHEN GROUPING(order_month) = 1 THEN 'ALL MONTHS' ELSE TO_CHAR(order_month, 'YYYY-MM') END AS month_label, CASE WHEN GROUPING(country) = 1 THEN 'ALL COUNTRIES' WHEN country IS NULL THEN 'NO COUNTRY' ELSE country END AS country_label, SUM(amount) AS revenue FROM prepared_orders GROUP BY ROLLUP (order_month, country) ORDER BY GROUPING(order_month), order_month, GROUPING(country), country;Так запрос становится проще читать: мы сначала готовим поля, а потом строим отчёт.
Стабильная сортировка итогов
Без
ORDER BYпорядок строк в SQL не гарантирован.Это особенно важно для
ROLLUP, потому что в результате смешиваются:Если не задать порядок явно, база может вернуть строки в удобном для себя порядке.
Для отчёта это плохо. Сегодня подытог может оказаться под деталями, завтра — выше них.
Поэтому сортировку лучше строить через
GROUPING().Например:
ORDER BY GROUPING(country), country, GROUPING(status), status;Идея такая:
countryучаствует в группировке;Для человекочитаемых отчётов часто нужно, чтобы подытог шёл после деталей.
Если нужна более гибкая сортировка, можно добавить техническую колонку:
CASE WHEN GROUPING(country) = 0 AND GROUPING(status) = 0 THEN 1 WHEN GROUPING(country) = 0 AND GROUPING(status) = 1 THEN 2 WHEN GROUPING(country) = 1 AND GROUPING(status) = 1 THEN 3 END AS row_level_sortИ потом сортировать по ней.
Добавляем row_level: уровень строки
Для API или BI часто полезно явно указать уровень строки.
Например:
SELECT CASE WHEN GROUPING(country) = 0 AND GROUPING(status) = 0 THEN 'detail' WHEN GROUPING(country) = 0 AND GROUPING(status) = 1 THEN 'country_total' WHEN GROUPING(country) = 1 AND GROUPING(status) = 1 THEN 'grand_total' END AS row_level, CASE WHEN GROUPING(country) = 1 THEN 'ALL COUNTRIES' WHEN country IS NULL THEN 'NO COUNTRY' ELSE country END AS country_label, CASE WHEN GROUPING(status) = 1 THEN 'ALL STATUSES' WHEN status IS NULL THEN 'NO STATUS' ELSE status END AS status_label, SUM(amount) AS revenue FROM orders GROUP BY ROLLUP (country, status) ORDER BY CASE WHEN GROUPING(country) = 0 AND GROUPING(status) = 0 THEN 1 WHEN GROUPING(country) = 0 AND GROUPING(status) = 1 THEN 2 WHEN GROUPING(country) = 1 AND GROUPING(status) = 1 THEN 3 END, country_label, status_label;Результат:
Теперь downstream-коду не нужно угадывать, какая строка является подытогом.
Это хороший паттерн для регулярных отчётов.
ROLLUP — это сокращение для GROUPING SETS
ROLLUPне отдельная магия. Это короткая запись дляGROUPING SETS.Например:
GROUP BY ROLLUP (country, status)эквивалентно:
GROUP BY GROUPING SETS ( (country, status), (country), () )То есть
ROLLUPпросто автоматически строит иерархию уровней.Для трёх колонок:
GROUP BY ROLLUP (year, month, day)это то же самое, что:
GROUP BY GROUPING SETS ( (year, month, day), (year, month), (year), () )Если вам нужна именно такая иерархия,
ROLLUPкороче и удобнее.Если же нужны нестандартные уровни, лучше использовать
GROUPING SETS.Когда использовать ROLLUP, а когда GROUPING SETS
ROLLUPхорош для естественных иерархий.Например:
В таких случаях подытоги имеют понятный смысл.
Например:
GROUP BY ROLLUP (country, city)даёт:
То есть города внутри страны, итог по стране и общий итог.
А вот если измерения независимы,
ROLLUPможет быть не тем, что нужно.Например:
Страна и способ оплаты не всегда образуют естественную иерархию. Если написать:
GROUP BY ROLLUP (country, payment_method)вы получите:
То есть будет итог по стране, но не будет отдельного итога по способу оплаты.
Если вам нужны и итоги по странам, и итоги по способам оплаты, и общий итог, лучше использовать
GROUPING SETSилиCUBE.Например:
GROUP BY GROUPING SETS ( (country, payment_method), (country), (payment_method), () )Или:
GROUP BY CUBE (country, payment_method)если нужны все комбинации.
ROLLUP против CUBE
ROLLUPстроит иерархию.ROLLUP (a, b)создаёт:
CUBEстроит все комбинации.CUBE (a, b)создаёт:
Разница важная.
Если у вас отчёт:
логичнее
ROLLUP.Если у вас независимые измерения:
и нужны итоги по каждому измерению отдельно, может подойти
CUBE.Можно запомнить так:
ROLLUP с HAVING
HAVINGможно использовать вместе сROLLUP.Например, оставить только строки, где выручка больше 1000:
SELECT country, status, SUM(amount) AS revenue FROM orders GROUP BY ROLLUP (country, status) HAVING SUM(amount) > 1000;Но здесь нужно быть осторожным.
HAVINGприменится ко всем уровням:Иногда это именно то, что нужно. А иногда вы хотите фильтровать только детальные строки, но оставить все подытоги.
Тогда используйте
GROUPING().Например:
SELECT country, status, SUM(amount) AS revenue FROM orders GROUP BY ROLLUP (country, status) HAVING GROUPING(country) = 1 OR GROUPING(status) = 1 OR SUM(amount) > 1000;Этот запрос оставит итоговые строки и детальные строки с выручкой больше 1000.
Но такие условия быстро становятся сложными. Для регулярных отчётов лучше явно добавлять
row_levelи аккуратно проектировать фильтрацию.ROLLUP и WHERE
Обычный
WHEREприменяется до группировки.Например:
SELECT country, status, SUM(amount) AS revenue FROM orders WHERE created_at >= DATE '2026-06-01' AND created_at < DATE '2026-07-01' GROUP BY ROLLUP (country, status);Здесь сначала выбираются только заказы за июнь 2026 года.
А потом по этим строкам строятся:
Это удобно: фильтр по периоду пишется один раз и применяется ко всем уровням отчёта.
Если бы вы делали отчёт через несколько
UNION ALL, этот фильтр пришлось бы повторять в каждой ветке.Почему ROLLUP лучше делать в SQL, а не в приложении
Иногда кажется, что можно просто получить детальные строки, а подытоги посчитать уже в приложении.
Например:
SELECT country, status, SUM(amount) FROM orders GROUP BY country, status;А потом в коде сложить суммы по странам и общий итог.
Так можно, но у этого подхода есть минусы:
ROLLUPпозволяет держать логику отчёта в SQL.База сама знает, как агрегировать данные, и возвращает готовый результат с деталями, подытогами и общим итогом.
Производительность ROLLUP
ROLLUPне означает, что база «бесплатно» добавляет итоги.Ей всё равно нужно выполнить агрегацию на нескольких уровнях.
Но обычно это лучше, чем писать несколько отдельных запросов с одинаковыми
WHERE,JOINиGROUP BY, а потом склеивать их черезUNION ALL.Например, вместо:
SELECT country, status, SUM(amount) FROM orders GROUP BY country, status UNION ALL SELECT country, NULL, SUM(amount) FROM orders GROUP BY country UNION ALL SELECT NULL, NULL, SUM(amount) FROM orders;можно написать:
SELECT country, status, SUM(amount) FROM orders GROUP BY ROLLUP (country, status);Так запрос короче, а планировщик может эффективнее работать с одной логикой агрегации.
Но на больших таблицах всё равно стоит проверять план через:
или:
Особенно если есть сложные
JOIN, фильтры, много уровней или тяжёлые агрегаты.ROLLUP в PostgreSQL
В PostgreSQL можно писать стандартный синтаксис:
GROUP BY ROLLUP (country, status)Также доступны:
GROUPING SETS CUBE GROUPING()Это делает PostgreSQL удобным для отчётных запросов с несколькими уровнями агрегации.
Пример:
SELECT country, status, SUM(amount) AS revenue, GROUPING(country) AS g_country, GROUPING(status) AS g_status FROM orders GROUP BY ROLLUP (country, status);Функция
GROUPING()особенно важна, если в данных могут быть настоящиеNULL.ROLLUP в MySQL
В MySQL используется другой синтаксис:
GROUP BY country, status WITH ROLLUPПример:
SELECT country, status, SUM(amount) AS revenue FROM orders GROUP BY country, status WITH ROLLUP;Идея похожая: сначала детализация по
country, status, потом подытоги и общий итог.Но возможности отличаются от PostgreSQL.
В MySQL нет такой же стандартной формы
GROUP BY ROLLUP (...), и поддержка функций вродеGROUPING()зависит от версии.Также с сортировкой рядом с
WITH ROLLUPнужно быть внимательнее: лучше проверять поведение на конкретной версии MySQL и не полагаться на случайный порядок строк.ROLLUP в ClickHouse
В ClickHouse тоже есть инструменты для итогов и подытогов, но синтаксис и поведение могут отличаться.
Можно встретить варианты вроде:
GROUP BY country, status WITH ROLLUPТакже в ClickHouse есть
WITH CUBEиWITH TOTALS.Для аналитических отчётов это полезно, но важно помнить:
ORDER BYне гарантирован;Если вы переносите запросы между PostgreSQL, MySQL и ClickHouse, проверяйте синтаксис и результат на реальных данных.
Практические шаблоны
Подытоги по стране и общий итог
SELECT country, status, SUM(amount) AS revenue FROM orders GROUP BY ROLLUP (country, status);То же самое с флагами GROUPING
SELECT GROUPING(country) AS g_country, GROUPING(status) AS g_status, country, status, SUM(amount) AS revenue FROM orders GROUP BY ROLLUP (country, status);Читаемые подписи итогов
SELECT CASE WHEN GROUPING(country) = 1 THEN 'ALL COUNTRIES' WHEN country IS NULL THEN 'NO COUNTRY' ELSE country END AS country_label, CASE WHEN GROUPING(status) = 1 THEN 'ALL STATUSES' WHEN status IS NULL THEN 'NO STATUS' ELSE status END AS status_label, SUM(amount) AS revenue FROM orders GROUP BY ROLLUP (country, status);Уровень строки для API или BI
SELECT CASE WHEN GROUPING(country) = 0 AND GROUPING(status) = 0 THEN 'detail' WHEN GROUPING(country) = 0 AND GROUPING(status) = 1 THEN 'country_total' WHEN GROUPING(country) = 1 AND GROUPING(status) = 1 THEN 'grand_total' END AS row_level, country, status, SUM(amount) AS revenue FROM orders GROUP BY ROLLUP (country, status);Отчёт по месяцам и странам
WITH prepared_orders AS ( SELECT DATE_TRUNC('month', o.created_at) AS order_month, u.country, o.amount FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status = 'paid' ) SELECT CASE WHEN GROUPING(order_month) = 1 THEN 'ALL MONTHS' ELSE TO_CHAR(order_month, 'YYYY-MM') END AS month_label, CASE WHEN GROUPING(country) = 1 THEN 'ALL COUNTRIES' WHEN country IS NULL THEN 'NO COUNTRY' ELSE country END AS country_label, SUM(amount) AS revenue FROM prepared_orders GROUP BY ROLLUP (order_month, country) ORDER BY GROUPING(order_month), order_month, GROUPING(country), country;Иерархия год → месяц → день
SELECT EXTRACT(YEAR FROM created_at) AS year, EXTRACT(MONTH FROM created_at) AS month, EXTRACT(DAY FROM created_at) AS day, SUM(amount) AS revenue FROM orders GROUP BY ROLLUP ( EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at), EXTRACT(DAY FROM created_at) );ROLLUP как GROUPING SETS
SELECT country, status, SUM(amount) AS revenue FROM orders GROUP BY GROUPING SETS ( (country, status), (country), () );Что важно запомнить
ROLLUPдобавляет к обычной группировке подытоги и общий итог.Пример:
SELECT country, status, SUM(amount) AS revenue FROM orders GROUP BY ROLLUP (country, status);ROLLUP (country, status)создаёт уровни:То есть:
Главные правила:
ROLLUPважен;nколонок создаётсяn + 1уровень агрегации;NULLв свернутых колонках;NULLиз данных и итоговыйNULLнужно различать черезGROUPING();GROUPING(col) = 1означает, что колонка свернута;row_level;ORDER BYне гарантирован;ROLLUP— это сокращение для частого случаяGROUPING SETS.Короткий вывод
ROLLUPнужен, когда отчёт строится по естественной иерархии и требует подытогов.Например:
Вместо нескольких запросов с
UNION ALLможно написать один:SELECT country, status, SUM(amount) AS revenue FROM orders GROUP BY ROLLUP (country, status);Главная мысль:
Но хороший
ROLLUP-отчёт — это не только короткий синтаксис.Нужно заранее продумать:
NULLот итоговой строки;Если это сделать,
ROLLUPпревращает сложный отчёт с подытогами в понятный и поддерживаемый SQL-запрос.