Ten artykuł jest obecnie po rosyjsku — trwa tłumaczenie na angielski.
Обычный GROUP BY группирует данные на одном уровне.
Например, можно посчитать сумму заказов по статусам:
SELECT
status,
SUM(amount) AS total
FROM orders
GROUP BY status;
Результат может быть таким:
status | total
---------+-------
paid | 5000
failed | 700
refunded | 900
Можно посчитать сумму заказов по пользователям:
SELECT
user_id,
SUM(amount) AS total
FROM orders
GROUP BY user_id;
Результат:
user_id | total
--------+-------
1 | 3800
2 | 900
3 | 1900
Можно посчитать общий итог:
SELECT
SUM(amount) AS total
FROM orders;
Результат:
total
-----
6600
Но что делать, если в одном отчёте нужны все эти уровни сразу?
Например:
- суммы по статусам;
- суммы по пользователям;
- общий итог по всем заказам.
Самый очевидный способ — написать несколько запросов и склеить их через UNION ALL.
Но в SQL для этого есть более удобный инструмент — GROUPING SETS.
Он позволяет в одном GROUP BY перечислить несколько разных уровней агрегации.
Проблема: несколько GROUP BY через UNION ALL
Допустим, есть таблица orders:
id | user_id | amount | status | created_at
---+---------+--------+----------+---------------------
1 | 1 | 1500 | paid | 2026-06-01 10:00:00
2 | 1 | 2300 | paid | 2026-06-02 12:00:00
3 | 2 | 900 | refunded | 2026-06-03 14:00:00
4 | 3 | 700 | failed | 2026-06-04 09:00:00
5 | 3 | 1200 | paid | 2026-06-05 11:00:00
Нужно получить отчёт:
- итог по каждому статусу;
- итог по каждому пользователю;
- общий итог.
Без GROUPING SETS можно написать так:
SELECT
status,
NULL::int AS user_id,
SUM(amount) AS total
FROM orders
GROUP BY status
UNION ALL
SELECT
NULL AS status,
user_id,
SUM(amount) AS total
FROM orders
GROUP BY user_id
UNION ALL
SELECT
NULL AS status,
NULL::int AS user_id,
SUM(amount) AS total
FROM orders;
Результат будет примерно таким:
status | user_id | total
---------+---------+-------
failed | NULL | 700
paid | NULL | 5000
refunded | NULL | 900
NULL | 1 | 3800
NULL | 2 | 900
NULL | 3 | 1900
NULL | NULL | 6600
Запрос работает, но у него есть проблемы.
Во-первых, таблица orders упоминается три раза.
Во-вторых, если появится общий фильтр, его нужно будет повторить в каждой ветке.
Например:
WHERE created_at >= DATE '2026-06-01'
AND created_at < DATE '2026-07-01'
Если в одной ветке случайно забыть этот фильтр, отчёт станет неверным.
В-третьих, если нужно добавить ещё один уровень агрегации, придётся добавлять ещё один SELECT.
На маленьком примере это терпимо.
В реальном отчёте с JOIN, фильтрами, правами доступа и десятком метрик такой запрос быстро превращается в простыню.
Решение: GROUPING SETS
GROUPING SETS позволяет описать несколько уровней группировки в одном запросе.
Тот же отчёт можно написать так:
SELECT
status,
user_id,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
(user_id),
()
);
Здесь мы перечислили три набора группировки:
(status) -- группировка по статусу
(user_id) -- группировка по пользователю
() -- общий итог
Пустые скобки () означают:
не группируй ни по какой колонке, посчитай общий итог по всему набору строк.
Результат будет похож на вариант с UNION ALL:
status | user_id | total
---------+---------+-------
failed | NULL | 700
paid | NULL | 5000
refunded | NULL | 900
NULL | 1 | 3800
NULL | 2 | 900
NULL | 3 | 1900
NULL | NULL | 6600
Но запрос стал короче и безопаснее.
Источник данных один.
Фильтры пишутся один раз.
Уровни агрегации видны в одном месте.
Как читать GROUPING SETS
Запрос:
SELECT
status,
user_id,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
(user_id),
()
);
можно прочитать так:
Возьми таблицу orders и посчитай SUM(amount) сразу на трёх уровнях: по status, по user_id и общий итог.
Каждый набор внутри GROUPING SETS — это отдельный вариант GROUP BY.
То есть:
GROUPING SETS (
(status),
(user_id),
()
)
по смыслу похож на три отдельных запроса:
GROUP BY status
GROUP BY user_id
(без GROUP BY)
Но вместо трёх веток UNION ALL мы описываем всё внутри одного GROUP BY.
Почему в результате появляются NULL
Когда текущий уровень агрегации не использует какую-то колонку, PostgreSQL выводит в ней NULL.
Например, строка по статусу:
status = paid
user_id = NULL
total = 5000
означает:
это итог по статусу paid, без детализации по пользователям.
Строка по пользователю:
status = NULL
user_id = 1
total = 3800
означает:
это итог по пользователю 1, без детализации по статусам.
Строка общего итога:
status = NULL
user_id = NULL
total = 6600
означает:
это общий итог по всем строкам.
На первый взгляд всё логично. Но здесь есть важная ловушка.
NULL может означать две разные вещи:
- колонка не участвует в текущем уровне группировки;
- в исходных данных действительно было значение
NULL.
И это нужно уметь различать.
Главная ловушка: настоящий NULL и строка итога выглядят одинаково
Допустим, в таблице orders есть заказы без статуса:
id | user_id | amount | status
---+---------+--------+---------
1 | 1 | 1500 | paid
2 | 2 | 900 | NULL
3 | 3 | 700 | failed
Теперь считаем суммы по статусам и общий итог:
SELECT
status,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
()
);
Результат может быть таким:
status | total
-------+-------
failed | 700
paid | 1500
NULL | 900
NULL | 3100
Проблема: здесь две строки с NULL.
Одна строка:
NULL | 900
означает заказы, у которых статус реально не заполнен.
Другая строка:
NULL | 3100
означает общий итог.
Глазами их легко перепутать.
Именно для этого в SQL есть функция GROUPING().
GROUPING(): как отличить итоговую строку от настоящего NULL
Функция GROUPING(column) показывает, участвует ли колонка в текущем уровне группировки.
Она возвращает:
0 -- колонка участвует в группировке
1 -- колонка свернута, то есть не участвует в текущем уровне
Пример:
SELECT
status,
GROUPING(status) AS status_grouping,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
()
);
Результат может быть таким:
status | status_grouping | total
-------+-----------------+-------
failed | 0 | 700
paid | 0 | 1500
NULL | 0 | 900
NULL | 1 | 3100
Теперь видно:
status_grouping = 0
значит status участвует в группировке. Если там NULL, это настоящий NULL из данных.
А:
status_grouping = 1
значит колонка status не участвует в текущем уровне группировки. Это итоговая строка.
Главное правило:
GROUPING(status) = 1 не значит «status равен NULL».
Это значит «status свернут на этом уровне агрегации».
Это разные вещи.
Человекочитаемые подписи для итогов
В отчётах лучше не оставлять загадочные NULL.
Вместо этого можно сразу подписать строки.
Например:
SELECT
CASE
WHEN GROUPING(status) = 1 THEN 'ALL STATUSES'
WHEN status IS NULL THEN 'NO STATUS'
ELSE status
END AS status_label,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
()
)
ORDER BY GROUPING(status), status_label;
Результат:
status_label | total
-------------+-------
NO STATUS | 900
failed | 700
paid | 1500
ALL STATUSES | 3100
Теперь отчёт читается нормально:
NO STATUS — это реальные заказы без статуса;
ALL STATUSES — это общий итог.
Это намного лучше, чем заставлять приложение или аналитика угадывать смысл NULL.
Пример: итоги по статусам, пользователям и общий итог
Вернёмся к исходной задаче.
Хотим получить:
- суммы по статусам;
- суммы по пользователям;
- общий итог.
Сделаем результат более понятным.
SELECT
CASE
WHEN GROUPING(status) = 1 THEN 'ALL STATUSES'
WHEN status IS NULL THEN 'NO STATUS'
ELSE status
END AS status_label,
CASE
WHEN GROUPING(user_id) = 1 THEN 'ALL USERS'
ELSE user_id::text
END AS user_label,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
(user_id),
()
)
ORDER BY
GROUPING(status),
status_label,
GROUPING(user_id),
user_label;
Результат может быть таким:
status_label | user_label | total
-------------+------------+-------
failed | ALL USERS | 700
paid | ALL USERS | 5000
refunded | ALL USERS | 900
ALL STATUSES | 1 | 3800
ALL STATUSES | 2 | 900
ALL STATUSES | 3 | 1900
ALL STATUSES | ALL USERS | 6600
Такой результат уже гораздо понятнее.
Видно, где строка по статусу, где строка по пользователю, а где общий итог.
Лучше добавить технический уровень отчёта
Для API, BI или дальнейшей обработки часто полезно добавить отдельную колонку с уровнем агрегации.
Например:
SELECT
CASE
WHEN GROUPING(status) = 0 AND GROUPING(user_id) = 1 THEN 'by_status'
WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 0 THEN 'by_user'
WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 1 THEN 'grand_total'
END AS row_level,
status,
user_id,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
(user_id),
()
);
Результат:
row_level | status | user_id | total
------------+----------+---------+-------
by_status | paid | NULL | 5000
by_status | failed | NULL | 700
by_user | NULL | 1 | 3800
by_user | NULL | 2 | 900
grand_total | NULL | NULL | 6600
Это хороший паттерн.
Почему?
Потому что downstream-коду не нужно угадывать уровень строки по NULL.
Он сразу видит:
by_status
by_user
grand_total
Если отчёт потом уедет в приложение, BI-систему или CSV, такая колонка сильно снижает риск неправильной интерпретации.
GROUPING SETS с обычным WHERE
Обычный WHERE работает до группировки.
Например, нужно построить отчёт только за июнь 2026 года:
SELECT
status,
user_id,
SUM(amount) AS total
FROM orders
WHERE created_at >= DATE '2026-06-01'
AND created_at < DATE '2026-07-01'
GROUP BY GROUPING SETS (
(status),
(user_id),
()
);
Здесь фильтр по датам применяется один раз ко всему входному набору.
Это одно из главных преимуществ GROUPING SETS по сравнению с несколькими UNION ALL.
В варианте через UNION ALL фильтр пришлось бы повторить в каждой ветке. А если в одной ветке его забыть, отчёт станет неконсистентным.
С GROUPING SETS источник данных один:
сначала фильтруем заказы за июнь
потом считаем несколько уровней агрегации
GROUPING SETS с JOIN
GROUPING SETS можно использовать и после соединения таблиц.
Например, есть users и orders. Нужно посчитать выручку:
- по странам;
- по статусам заказов;
- общий итог.
SELECT
u.country,
o.status,
SUM(o.amount) AS revenue
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status IN ('paid', 'refunded')
GROUP BY GROUPING SETS (
(u.country),
(o.status),
()
);
Смысл:
(u.country) -- выручка по странам
(o.status) -- выручка по статусам
() -- общий итог
Но опять же, лучше добавить подписи и уровень строки:
SELECT
CASE
WHEN GROUPING(u.country) = 0 AND GROUPING(o.status) = 1 THEN 'by_country'
WHEN GROUPING(u.country) = 1 AND GROUPING(o.status) = 0 THEN 'by_status'
WHEN GROUPING(u.country) = 1 AND GROUPING(o.status) = 1 THEN 'grand_total'
END AS row_level,
CASE
WHEN GROUPING(u.country) = 1 THEN 'ALL COUNTRIES'
WHEN u.country IS NULL THEN 'NO COUNTRY'
ELSE u.country
END AS country_label,
CASE
WHEN GROUPING(o.status) = 1 THEN 'ALL STATUSES'
WHEN o.status IS NULL THEN 'NO STATUS'
ELSE o.status
END AS status_label,
SUM(o.amount) AS revenue
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status IN ('paid', 'refunded')
GROUP BY GROUPING SETS (
(u.country),
(o.status),
()
);
Такой запрос длиннее, но зато результат становится понятным и безопасным для использования.
Чем GROUPING SETS лучше UNION ALL
GROUPING SETS часто заменяет пачку похожих запросов с UNION ALL.
Вместо такого подхода:
SELECT status, NULL::int AS user_id, SUM(amount)
FROM orders
WHERE created_at >= DATE '2026-06-01'
GROUP BY status
UNION ALL
SELECT NULL, user_id, SUM(amount)
FROM orders
WHERE created_at >= DATE '2026-06-01'
GROUP BY user_id
UNION ALL
SELECT NULL, NULL, SUM(amount)
FROM orders
WHERE created_at >= DATE '2026-06-01';
можно написать:
SELECT
status,
user_id,
SUM(amount) AS total
FROM orders
WHERE created_at >= DATE '2026-06-01'
GROUP BY GROUPING SETS (
(status),
(user_id),
()
);
Плюсы:
- источник данных описан один раз;
- фильтры пишутся один раз;
JOIN пишется один раз;
- легче добавить новый уровень агрегации;
- меньше риска, что ветки отчёта разъедутся;
- запрос проще ревьюить;
- уровни агрегации видны в одном месте.
Это особенно важно в отчётах, где рядом должны стоять метрики, рассчитанные на одном и том же наборе данных.
Когда GROUPING SETS особенно полезен
GROUPING SETS хорошо подходит для отчётов, где нужны разные срезы одной и той же метрики.
Например:
- продажи по статусам и общий итог;
- выручка по странам, по пользователям и общий итог;
- зарплатный фонд по отделам, по менеджерам и общий итог;
- количество заказов по дням, по статусам и общий итог;
- сверка витрины на нескольких уровнях детализации.
Классический случай:
одна таблица фактов
один набор фильтров
несколько уровней группировки
Например:
SELECT
status,
DATE_TRUNC('month', created_at) AS month,
SUM(amount) AS total
FROM orders
WHERE created_at >= DATE '2026-01-01'
GROUP BY GROUPING SETS (
(status),
(DATE_TRUNC('month', created_at)),
()
);
Так можно получить в одном запросе:
- итоги по статусам;
- итоги по месяцам;
- общий итог.
Когда GROUPING SETS может быть неудобен
GROUPING SETS не стоит использовать просто ради красоты.
Если результат потом сложно читать, а приложение не понимает, где детальная строка, где подытог, а где общий итог, отчёт может стать опасным.
Особенно если вы отдаёте результат наружу.
Плохой признак:
в отчёте много NULL,
а их смысл нужно угадывать
Лучше сразу добавлять:
row_level;
- человекочитаемые подписи;
GROUPING()-колонки;
- стабильный
ORDER BY.
Если отчёт становится слишком сложным, иногда лучше разделить его на несколько отдельных запросов или подготовить отдельную витрину.
GROUPING SETS силён тогда, когда разные уровни агрегации действительно относятся к одному отчёту и должны считаться из одного источника.
ROLLUP: иерархические итоги
ROLLUP — это сокращение для частого случая GROUPING SETS.
Он строит иерархию итогов.
Например, есть таблица employees:
id | name | dept | manager_id | salary
---+------+------+------------+--------
1 | Anna | eng | NULL | 250000
2 | Bob | eng | 1 | 180000
3 | Kate | eng | 1 | 170000
4 | Tom | hr | NULL | 160000
5 | Max | hr | 4 | 120000
Хотим посчитать зарплатный фонд:
- по отделу и менеджеру;
- подытог по отделу;
- общий итог.
Можно написать:
SELECT
dept,
manager_id,
SUM(salary) AS payroll
FROM employees
GROUP BY ROLLUP (dept, manager_id);
ROLLUP (dept, manager_id) разворачивается примерно в такие уровни:
(dept, manager_id)
(dept)
()
То есть:
- детализация по отделу и менеджеру;
- итог по отделу;
- общий итог.
Это удобно для иерархических отчётов.
Например:
отдел → менеджер → общий итог
страна → город → общий итог
год → месяц → общий итог
Пример ROLLUP с GROUPING()
Чтобы итоговые строки было проще читать, добавим GROUPING().
SELECT
CASE
WHEN GROUPING(dept) = 1 THEN 'ALL DEPARTMENTS'
WHEN dept IS NULL THEN 'NO DEPT'
ELSE dept
END AS dept_label,
CASE
WHEN GROUPING(manager_id) = 1 THEN 'ALL MANAGERS'
ELSE manager_id::text
END AS manager_label,
SUM(salary) AS payroll,
GROUPING(dept) AS g_dept,
GROUPING(manager_id) AS g_manager
FROM employees
GROUP BY ROLLUP (dept, manager_id)
ORDER BY
dept_label,
GROUPING(manager_id),
manager_label;
Результат может быть таким:
dept_label | manager_label | payroll | g_dept | g_manager
----------------+---------------+---------+--------+----------
eng | 1 | 350000 | 0 | 0
eng | ALL MANAGERS | 600000 | 0 | 1
hr | 4 | 120000 | 0 | 0
hr | ALL MANAGERS | 280000 | 0 | 1
ALL DEPARTMENTS | ALL MANAGERS | 880000 | 1 | 1
ROLLUP особенно хорошо подходит для отчётов с подытогами.
CUBE: все комбинации измерений
CUBE — ещё одно сокращение.
Если ROLLUP строит иерархию, то CUBE строит все комбинации измерений.
Например:
SELECT
dept,
manager_id,
SUM(salary) AS payroll
FROM employees
GROUP BY CUBE (dept, manager_id);
CUBE (dept, manager_id) разворачивается в:
(dept, manager_id)
(dept)
(manager_id)
()
То есть результат содержит:
- итоги по паре
dept + manager_id;
- итоги по
dept;
- итоги по
manager_id;
- общий итог.
Для двух колонок это ещё читаемо.
Для трёх колонок комбинаций уже больше:
(a, b, c)
(a, b)
(a, c)
(b, c)
(a)
(b)
(c)
()
Поэтому CUBE нужно использовать аккуратно: он может резко увеличить количество строк в результате.
Можно запомнить так:
ROLLUP — иерархия.
CUBE — все комбинации.
GROUPING SETS — вручную выбранные уровни.
GROUPING с несколькими колонками
Функция GROUPING() может принимать несколько колонок.
Например:
GROUPING(dept, manager_id)
Она возвращает числовую битовую маску, которая показывает, какие колонки были свернуты.
В PostgreSQL правый аргумент соответствует младшему биту.
Для GROUPING(dept, manager_id) значения можно понимать так:
0 -- dept участвует, manager_id участвует
1 -- dept участвует, manager_id свернут
2 -- dept свернут, manager_id участвует
3 -- dept свернут, manager_id свернут
Например:
SELECT
dept,
manager_id,
SUM(salary) AS payroll,
GROUPING(dept, manager_id) AS grouping_mask
FROM employees
GROUP BY CUBE (dept, manager_id)
ORDER BY grouping_mask, dept, manager_id;
Такая маска удобна, если результат обрабатывает приложение или BI-система.
Но для человека часто понятнее отдельная колонка row_level.
Например:
CASE GROUPING(dept, manager_id)
WHEN 0 THEN 'by_dept_and_manager'
WHEN 1 THEN 'by_dept'
WHEN 2 THEN 'by_manager'
WHEN 3 THEN 'grand_total'
END AS row_level
Так результат проще читать и использовать.
GROUPING SETS, ROLLUP и CUBE: что выбрать
Все три инструмента решают похожую задачу, но используются в разных случаях.
GROUPING SETS
Используйте, когда нужны конкретные, заранее выбранные уровни.
Например:
GROUP BY GROUPING SETS (
(status),
(user_id),
()
)
Это значит:
по статусу
по пользователю
общий итог
Но не нужно считать комбинацию (status, user_id).
ROLLUP
Используйте для иерархии.
Например:
GROUP BY ROLLUP (country, city)
Это значит:
(country, city)
(country)
()
То есть:
город внутри страны
итог по стране
общий итог
CUBE
Используйте, когда нужны все комбинации измерений.
Например:
GROUP BY CUBE (country, status)
Это значит:
(country, status)
(country)
(status)
()
То есть:
по стране и статусу
по стране
по статусу
общий итог
Но помните: чем больше колонок в CUBE, тем больше уровней и строк получится.
Стабильная сортировка результата
При GROUPING SETS в одном результате смешиваются разные уровни:
- детальные строки;
- подытоги;
- общий итог.
Без ORDER BY порядок строк не гарантирован.
Лучше задавать сортировку явно.
Например:
SELECT
CASE
WHEN GROUPING(status) = 1 THEN 'ALL STATUSES'
ELSE status
END AS status_label,
CASE
WHEN GROUPING(user_id) = 1 THEN 'ALL USERS'
ELSE user_id::text
END AS user_label,
SUM(amount) AS total,
GROUPING(status, user_id) AS g
FROM orders
GROUP BY GROUPING SETS (
(status),
(user_id),
()
)
ORDER BY
g,
status_label,
user_label;
Если результат идёт в BI или приложение, лучше заранее договориться, как именно сортируются уровни.
Например:
1. строки по статусам
2. строки по пользователям
3. общий итог
Для этого удобно использовать row_level и сортировочную колонку.
SELECT
CASE
WHEN GROUPING(status) = 0 AND GROUPING(user_id) = 1 THEN 1
WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 0 THEN 2
WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 1 THEN 3
END AS sort_level,
CASE
WHEN GROUPING(status) = 0 AND GROUPING(user_id) = 1 THEN 'by_status'
WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 0 THEN 'by_user'
WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 1 THEN 'grand_total'
END AS row_level,
status,
user_id,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
(user_id),
()
)
ORDER BY sort_level, status, user_id;
Так результат будет стабильным и предсказуемым.
GROUPING SETS и HAVING
HAVING тоже можно использовать с GROUPING SETS.
Например, хотим оставить только уровни, где сумма больше 1000:
SELECT
status,
user_id,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
(user_id),
()
)
HAVING SUM(amount) > 1000;
Но с HAVING нужно быть аккуратным: он применится ко всем уровням агрегации.
Если нужно фильтровать только конкретный уровень, используйте GROUPING().
Например, оставить все итоговые строки и только пользователей с суммой больше 1000:
SELECT
status,
user_id,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
(user_id),
()
)
HAVING
GROUPING(user_id) = 1
OR SUM(amount) > 1000;
Но такие условия могут быстро стать сложными. В отчётах лучше явно добавлять row_level, чтобы понимать, к каким строкам применяется фильтр.
Совместимость в разных СУБД
В PostgreSQL поддерживаются:
GROUPING SETS
ROLLUP
CUBE
GROUPING()
Это удобный и зрелый инструмент для отчётных запросов.
В MySQL полноценного GROUPING SETS обычно нет. Там чаще встречается синтаксис:
GROUP BY column_1, column_2 WITH ROLLUP
Например:
SELECT
status,
user_id,
SUM(amount) AS total
FROM orders
GROUP BY status, user_id WITH ROLLUP;
Это похоже на ROLLUP, но не заменяет все возможности GROUPING SETS и CUBE.
В ClickHouse поддержка операций вроде GROUPING SETS, ROLLUP, CUBE и grouping() зависит от версии и настроек, но сама идея похожая: можно получать несколько уровней агрегации одним запросом. Как и всегда в ClickHouse, порядок результата без явного ORDER BY не стоит считать стабильным.
Если вы пишете переносимый SQL, заранее проверяйте поддержку в вашей СУБД.
Практические шаблоны
Итоги по статусам и общий итог
SELECT
status,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
()
);
Итоги по статусам, пользователям и общий итог
SELECT
status,
user_id,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
(user_id),
()
);
То же самое с уровнем строки
SELECT
CASE
WHEN GROUPING(status) = 0 AND GROUPING(user_id) = 1 THEN 'by_status'
WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 0 THEN 'by_user'
WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 1 THEN 'grand_total'
END AS row_level,
status,
user_id,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
(user_id),
()
);
Подписи вместо итоговых NULL
SELECT
CASE
WHEN GROUPING(status) = 1 THEN 'ALL STATUSES'
WHEN status IS NULL THEN 'NO STATUS'
ELSE status
END AS status_label,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
()
);
Иерархический отчёт через ROLLUP
SELECT
dept,
manager_id,
SUM(salary) AS payroll
FROM employees
GROUP BY ROLLUP (dept, manager_id);
Все комбинации через CUBE
SELECT
dept,
manager_id,
SUM(salary) AS payroll
FROM employees
GROUP BY CUBE (dept, manager_id);
Маска группировки
SELECT
dept,
manager_id,
SUM(salary) AS payroll,
GROUPING(dept, manager_id) AS grouping_mask
FROM employees
GROUP BY CUBE (dept, manager_id);
Отчёт за период на нескольких уровнях
SELECT
status,
DATE_TRUNC('month', created_at) AS month,
SUM(amount) AS total
FROM orders
WHERE created_at >= DATE '2026-01-01'
AND created_at < DATE '2027-01-01'
GROUP BY GROUPING SETS (
(status),
(DATE_TRUNC('month', created_at)),
()
);
Что важно запомнить
GROUPING SETS позволяет посчитать несколько уровней агрегации в одном запросе.
Например:
SELECT
status,
user_id,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
(user_id),
()
);
Здесь считаются:
(status) -- итоги по статусам
(user_id) -- итоги по пользователям
() -- общий итог
Главные правила:
GROUPING SETS перечисляет наборы колонок для группировки;
- пустой набор
() означает общий итог;
- колонки, не участвующие в текущем уровне, выводятся как
NULL;
- настоящий
NULL из данных и итоговый NULL нужно различать через GROUPING();
GROUPING(col) = 1 означает, что колонка свернута;
GROUPING(col) = 0 означает, что колонка участвует в группировке;
ROLLUP подходит для иерархий;
CUBE строит все комбинации измерений;
- результат лучше снабжать
row_level и стабильным ORDER BY.
Короткий вывод
GROUPING SETS нужен, когда отчёт требует несколько срезов одной и той же метрики.
Вместо нескольких похожих запросов:
SELECT ... GROUP BY status
UNION ALL
SELECT ... GROUP BY user_id
UNION ALL
SELECT ...
можно написать один запрос:
SELECT
status,
user_id,
SUM(amount) AS total
FROM orders
GROUP BY GROUPING SETS (
(status),
(user_id),
()
);
Это делает SQL короче, безопаснее и понятнее.
Главная мысль:
GROUPING SETS позволяет описать несколько уровней агрегации в одном GROUP BY.
Но вместе с этим нужно аккуратно проектировать результат.
Не оставляйте downstream-коду загадочные NULL. Добавляйте GROUPING(), row_level, понятные подписи и стабильную сортировку. Тогда отчёт будет не только компактным, но и безопасным для реального использования.
Обычный
GROUP BYгруппирует данные на одном уровне.Например, можно посчитать сумму заказов по статусам:
SELECT status, SUM(amount) AS total FROM orders GROUP BY status;Результат может быть таким:
Можно посчитать сумму заказов по пользователям:
SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id;Результат:
Можно посчитать общий итог:
SELECT SUM(amount) AS total FROM orders;Результат:
Но что делать, если в одном отчёте нужны все эти уровни сразу?
Например:
Самый очевидный способ — написать несколько запросов и склеить их через
UNION ALL.Но в SQL для этого есть более удобный инструмент —
GROUPING SETS.Он позволяет в одном
GROUP BYперечислить несколько разных уровней агрегации.Проблема: несколько GROUP BY через UNION ALL
Допустим, есть таблица
orders:Нужно получить отчёт:
Без
GROUPING SETSможно написать так:SELECT status, NULL::int AS user_id, SUM(amount) AS total FROM orders GROUP BY status UNION ALL SELECT NULL AS status, user_id, SUM(amount) AS total FROM orders GROUP BY user_id UNION ALL SELECT NULL AS status, NULL::int AS user_id, SUM(amount) AS total FROM orders;Результат будет примерно таким:
Запрос работает, но у него есть проблемы.
Во-первых, таблица
ordersупоминается три раза.Во-вторых, если появится общий фильтр, его нужно будет повторить в каждой ветке.
Например:
WHERE created_at >= DATE '2026-06-01' AND created_at < DATE '2026-07-01'Если в одной ветке случайно забыть этот фильтр, отчёт станет неверным.
В-третьих, если нужно добавить ещё один уровень агрегации, придётся добавлять ещё один
SELECT.На маленьком примере это терпимо. В реальном отчёте с
JOIN, фильтрами, правами доступа и десятком метрик такой запрос быстро превращается в простыню.Решение: GROUPING SETS
GROUPING SETSпозволяет описать несколько уровней группировки в одном запросе.Тот же отчёт можно написать так:
SELECT status, user_id, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), (user_id), () );Здесь мы перечислили три набора группировки:
Пустые скобки
()означают:Результат будет похож на вариант с
UNION ALL:Но запрос стал короче и безопаснее.
Источник данных один. Фильтры пишутся один раз. Уровни агрегации видны в одном месте.
Как читать GROUPING SETS
Запрос:
SELECT status, user_id, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), (user_id), () );можно прочитать так:
Каждый набор внутри
GROUPING SETS— это отдельный вариантGROUP BY.То есть:
GROUPING SETS ( (status), (user_id), () )по смыслу похож на три отдельных запроса:
Но вместо трёх веток
UNION ALLмы описываем всё внутри одногоGROUP BY.Почему в результате появляются NULL
Когда текущий уровень агрегации не использует какую-то колонку, PostgreSQL выводит в ней
NULL.Например, строка по статусу:
означает:
Строка по пользователю:
означает:
Строка общего итога:
означает:
На первый взгляд всё логично. Но здесь есть важная ловушка.
NULLможет означать две разные вещи:NULL.И это нужно уметь различать.
Главная ловушка: настоящий NULL и строка итога выглядят одинаково
Допустим, в таблице
ordersесть заказы без статуса:Теперь считаем суммы по статусам и общий итог:
SELECT status, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), () );Результат может быть таким:
Проблема: здесь две строки с
NULL.Одна строка:
означает заказы, у которых статус реально не заполнен.
Другая строка:
означает общий итог.
Глазами их легко перепутать.
Именно для этого в SQL есть функция
GROUPING().GROUPING(): как отличить итоговую строку от настоящего NULL
Функция
GROUPING(column)показывает, участвует ли колонка в текущем уровне группировки.Она возвращает:
Пример:
SELECT status, GROUPING(status) AS status_grouping, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), () );Результат может быть таким:
Теперь видно:
значит
statusучаствует в группировке. Если тамNULL, это настоящийNULLиз данных.А:
значит колонка
statusне участвует в текущем уровне группировки. Это итоговая строка.Главное правило:
Это разные вещи.
Человекочитаемые подписи для итогов
В отчётах лучше не оставлять загадочные
NULL.Вместо этого можно сразу подписать строки.
Например:
SELECT CASE WHEN GROUPING(status) = 1 THEN 'ALL STATUSES' WHEN status IS NULL THEN 'NO STATUS' ELSE status END AS status_label, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), () ) ORDER BY GROUPING(status), status_label;Результат:
Теперь отчёт читается нормально:
NO STATUS— это реальные заказы без статуса;ALL STATUSES— это общий итог.Это намного лучше, чем заставлять приложение или аналитика угадывать смысл
NULL.Пример: итоги по статусам, пользователям и общий итог
Вернёмся к исходной задаче.
Хотим получить:
Сделаем результат более понятным.
SELECT CASE WHEN GROUPING(status) = 1 THEN 'ALL STATUSES' WHEN status IS NULL THEN 'NO STATUS' ELSE status END AS status_label, CASE WHEN GROUPING(user_id) = 1 THEN 'ALL USERS' ELSE user_id::text END AS user_label, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), (user_id), () ) ORDER BY GROUPING(status), status_label, GROUPING(user_id), user_label;Результат может быть таким:
Такой результат уже гораздо понятнее.
Видно, где строка по статусу, где строка по пользователю, а где общий итог.
Лучше добавить технический уровень отчёта
Для API, BI или дальнейшей обработки часто полезно добавить отдельную колонку с уровнем агрегации.
Например:
SELECT CASE WHEN GROUPING(status) = 0 AND GROUPING(user_id) = 1 THEN 'by_status' WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 0 THEN 'by_user' WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 1 THEN 'grand_total' END AS row_level, status, user_id, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), (user_id), () );Результат:
Это хороший паттерн.
Почему?
Потому что downstream-коду не нужно угадывать уровень строки по
NULL.Он сразу видит:
Если отчёт потом уедет в приложение, BI-систему или CSV, такая колонка сильно снижает риск неправильной интерпретации.
GROUPING SETS с обычным WHERE
Обычный
WHEREработает до группировки.Например, нужно построить отчёт только за июнь 2026 года:
SELECT status, user_id, SUM(amount) AS total FROM orders WHERE created_at >= DATE '2026-06-01' AND created_at < DATE '2026-07-01' GROUP BY GROUPING SETS ( (status), (user_id), () );Здесь фильтр по датам применяется один раз ко всему входному набору.
Это одно из главных преимуществ
GROUPING SETSпо сравнению с несколькимиUNION ALL.В варианте через
UNION ALLфильтр пришлось бы повторить в каждой ветке. А если в одной ветке его забыть, отчёт станет неконсистентным.С
GROUPING SETSисточник данных один:GROUPING SETS с JOIN
GROUPING SETSможно использовать и после соединения таблиц.Например, есть
usersиorders. Нужно посчитать выручку:SELECT u.country, o.status, SUM(o.amount) AS revenue FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status IN ('paid', 'refunded') GROUP BY GROUPING SETS ( (u.country), (o.status), () );Смысл:
Но опять же, лучше добавить подписи и уровень строки:
SELECT CASE WHEN GROUPING(u.country) = 0 AND GROUPING(o.status) = 1 THEN 'by_country' WHEN GROUPING(u.country) = 1 AND GROUPING(o.status) = 0 THEN 'by_status' WHEN GROUPING(u.country) = 1 AND GROUPING(o.status) = 1 THEN 'grand_total' END AS row_level, CASE WHEN GROUPING(u.country) = 1 THEN 'ALL COUNTRIES' WHEN u.country IS NULL THEN 'NO COUNTRY' ELSE u.country END AS country_label, CASE WHEN GROUPING(o.status) = 1 THEN 'ALL STATUSES' WHEN o.status IS NULL THEN 'NO STATUS' ELSE o.status END AS status_label, SUM(o.amount) AS revenue FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status IN ('paid', 'refunded') GROUP BY GROUPING SETS ( (u.country), (o.status), () );Такой запрос длиннее, но зато результат становится понятным и безопасным для использования.
Чем GROUPING SETS лучше UNION ALL
GROUPING SETSчасто заменяет пачку похожих запросов сUNION ALL.Вместо такого подхода:
SELECT status, NULL::int AS user_id, SUM(amount) FROM orders WHERE created_at >= DATE '2026-06-01' GROUP BY status UNION ALL SELECT NULL, user_id, SUM(amount) FROM orders WHERE created_at >= DATE '2026-06-01' GROUP BY user_id UNION ALL SELECT NULL, NULL, SUM(amount) FROM orders WHERE created_at >= DATE '2026-06-01';можно написать:
SELECT status, user_id, SUM(amount) AS total FROM orders WHERE created_at >= DATE '2026-06-01' GROUP BY GROUPING SETS ( (status), (user_id), () );Плюсы:
JOINпишется один раз;Это особенно важно в отчётах, где рядом должны стоять метрики, рассчитанные на одном и том же наборе данных.
Когда GROUPING SETS особенно полезен
GROUPING SETSхорошо подходит для отчётов, где нужны разные срезы одной и той же метрики.Например:
Классический случай:
Например:
SELECT status, DATE_TRUNC('month', created_at) AS month, SUM(amount) AS total FROM orders WHERE created_at >= DATE '2026-01-01' GROUP BY GROUPING SETS ( (status), (DATE_TRUNC('month', created_at)), () );Так можно получить в одном запросе:
Когда GROUPING SETS может быть неудобен
GROUPING SETSне стоит использовать просто ради красоты.Если результат потом сложно читать, а приложение не понимает, где детальная строка, где подытог, а где общий итог, отчёт может стать опасным.
Особенно если вы отдаёте результат наружу.
Плохой признак:
Лучше сразу добавлять:
row_level;GROUPING()-колонки;ORDER BY.Если отчёт становится слишком сложным, иногда лучше разделить его на несколько отдельных запросов или подготовить отдельную витрину.
GROUPING SETSсилён тогда, когда разные уровни агрегации действительно относятся к одному отчёту и должны считаться из одного источника.ROLLUP: иерархические итоги
ROLLUP— это сокращение для частого случаяGROUPING SETS.Он строит иерархию итогов.
Например, есть таблица
employees:Хотим посчитать зарплатный фонд:
Можно написать:
SELECT dept, manager_id, SUM(salary) AS payroll FROM employees GROUP BY ROLLUP (dept, manager_id);ROLLUP (dept, manager_id)разворачивается примерно в такие уровни:То есть:
Это удобно для иерархических отчётов.
Например:
Пример ROLLUP с GROUPING()
Чтобы итоговые строки было проще читать, добавим
GROUPING().SELECT CASE WHEN GROUPING(dept) = 1 THEN 'ALL DEPARTMENTS' WHEN dept IS NULL THEN 'NO DEPT' ELSE dept END AS dept_label, CASE WHEN GROUPING(manager_id) = 1 THEN 'ALL MANAGERS' ELSE manager_id::text END AS manager_label, SUM(salary) AS payroll, GROUPING(dept) AS g_dept, GROUPING(manager_id) AS g_manager FROM employees GROUP BY ROLLUP (dept, manager_id) ORDER BY dept_label, GROUPING(manager_id), manager_label;Результат может быть таким:
ROLLUPособенно хорошо подходит для отчётов с подытогами.CUBE: все комбинации измерений
CUBE— ещё одно сокращение.Если
ROLLUPстроит иерархию, тоCUBEстроит все комбинации измерений.Например:
SELECT dept, manager_id, SUM(salary) AS payroll FROM employees GROUP BY CUBE (dept, manager_id);CUBE (dept, manager_id)разворачивается в:То есть результат содержит:
dept + manager_id;dept;manager_id;Для двух колонок это ещё читаемо.
Для трёх колонок комбинаций уже больше:
Поэтому
CUBEнужно использовать аккуратно: он может резко увеличить количество строк в результате.Можно запомнить так:
GROUPING с несколькими колонками
Функция
GROUPING()может принимать несколько колонок.Например:
GROUPING(dept, manager_id)Она возвращает числовую битовую маску, которая показывает, какие колонки были свернуты.
В PostgreSQL правый аргумент соответствует младшему биту.
Для
GROUPING(dept, manager_id)значения можно понимать так:Например:
SELECT dept, manager_id, SUM(salary) AS payroll, GROUPING(dept, manager_id) AS grouping_mask FROM employees GROUP BY CUBE (dept, manager_id) ORDER BY grouping_mask, dept, manager_id;Такая маска удобна, если результат обрабатывает приложение или BI-система.
Но для человека часто понятнее отдельная колонка
row_level.Например:
CASE GROUPING(dept, manager_id) WHEN 0 THEN 'by_dept_and_manager' WHEN 1 THEN 'by_dept' WHEN 2 THEN 'by_manager' WHEN 3 THEN 'grand_total' END AS row_levelТак результат проще читать и использовать.
GROUPING SETS, ROLLUP и CUBE: что выбрать
Все три инструмента решают похожую задачу, но используются в разных случаях.
GROUPING SETS
Используйте, когда нужны конкретные, заранее выбранные уровни.
Например:
GROUP BY GROUPING SETS ( (status), (user_id), () )Это значит:
Но не нужно считать комбинацию
(status, user_id).ROLLUP
Используйте для иерархии.
Например:
GROUP BY ROLLUP (country, city)Это значит:
То есть:
CUBE
Используйте, когда нужны все комбинации измерений.
Например:
GROUP BY CUBE (country, status)Это значит:
То есть:
Но помните: чем больше колонок в
CUBE, тем больше уровней и строк получится.Стабильная сортировка результата
При
GROUPING SETSв одном результате смешиваются разные уровни:Без
ORDER BYпорядок строк не гарантирован.Лучше задавать сортировку явно.
Например:
SELECT CASE WHEN GROUPING(status) = 1 THEN 'ALL STATUSES' ELSE status END AS status_label, CASE WHEN GROUPING(user_id) = 1 THEN 'ALL USERS' ELSE user_id::text END AS user_label, SUM(amount) AS total, GROUPING(status, user_id) AS g FROM orders GROUP BY GROUPING SETS ( (status), (user_id), () ) ORDER BY g, status_label, user_label;Если результат идёт в BI или приложение, лучше заранее договориться, как именно сортируются уровни.
Например:
Для этого удобно использовать
row_levelи сортировочную колонку.SELECT CASE WHEN GROUPING(status) = 0 AND GROUPING(user_id) = 1 THEN 1 WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 0 THEN 2 WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 1 THEN 3 END AS sort_level, CASE WHEN GROUPING(status) = 0 AND GROUPING(user_id) = 1 THEN 'by_status' WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 0 THEN 'by_user' WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 1 THEN 'grand_total' END AS row_level, status, user_id, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), (user_id), () ) ORDER BY sort_level, status, user_id;Так результат будет стабильным и предсказуемым.
GROUPING SETS и HAVING
HAVINGтоже можно использовать сGROUPING SETS.Например, хотим оставить только уровни, где сумма больше 1000:
SELECT status, user_id, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), (user_id), () ) HAVING SUM(amount) > 1000;Но с
HAVINGнужно быть аккуратным: он применится ко всем уровням агрегации.Если нужно фильтровать только конкретный уровень, используйте
GROUPING().Например, оставить все итоговые строки и только пользователей с суммой больше 1000:
SELECT status, user_id, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), (user_id), () ) HAVING GROUPING(user_id) = 1 OR SUM(amount) > 1000;Но такие условия могут быстро стать сложными. В отчётах лучше явно добавлять
row_level, чтобы понимать, к каким строкам применяется фильтр.Совместимость в разных СУБД
В PostgreSQL поддерживаются:
GROUPING SETS ROLLUP CUBE GROUPING()Это удобный и зрелый инструмент для отчётных запросов.
В MySQL полноценного
GROUPING SETSобычно нет. Там чаще встречается синтаксис:GROUP BY column_1, column_2 WITH ROLLUPНапример:
SELECT status, user_id, SUM(amount) AS total FROM orders GROUP BY status, user_id WITH ROLLUP;Это похоже на
ROLLUP, но не заменяет все возможностиGROUPING SETSиCUBE.В ClickHouse поддержка операций вроде
GROUPING SETS,ROLLUP,CUBEиgrouping()зависит от версии и настроек, но сама идея похожая: можно получать несколько уровней агрегации одним запросом. Как и всегда в ClickHouse, порядок результата без явногоORDER BYне стоит считать стабильным.Если вы пишете переносимый SQL, заранее проверяйте поддержку в вашей СУБД.
Практические шаблоны
Итоги по статусам и общий итог
SELECT status, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), () );Итоги по статусам, пользователям и общий итог
SELECT status, user_id, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), (user_id), () );То же самое с уровнем строки
SELECT CASE WHEN GROUPING(status) = 0 AND GROUPING(user_id) = 1 THEN 'by_status' WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 0 THEN 'by_user' WHEN GROUPING(status) = 1 AND GROUPING(user_id) = 1 THEN 'grand_total' END AS row_level, status, user_id, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), (user_id), () );Подписи вместо итоговых NULL
SELECT CASE WHEN GROUPING(status) = 1 THEN 'ALL STATUSES' WHEN status IS NULL THEN 'NO STATUS' ELSE status END AS status_label, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), () );Иерархический отчёт через ROLLUP
SELECT dept, manager_id, SUM(salary) AS payroll FROM employees GROUP BY ROLLUP (dept, manager_id);Все комбинации через CUBE
SELECT dept, manager_id, SUM(salary) AS payroll FROM employees GROUP BY CUBE (dept, manager_id);Маска группировки
SELECT dept, manager_id, SUM(salary) AS payroll, GROUPING(dept, manager_id) AS grouping_mask FROM employees GROUP BY CUBE (dept, manager_id);Отчёт за период на нескольких уровнях
SELECT status, DATE_TRUNC('month', created_at) AS month, SUM(amount) AS total FROM orders WHERE created_at >= DATE '2026-01-01' AND created_at < DATE '2027-01-01' GROUP BY GROUPING SETS ( (status), (DATE_TRUNC('month', created_at)), () );Что важно запомнить
GROUPING SETSпозволяет посчитать несколько уровней агрегации в одном запросе.Например:
SELECT status, user_id, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), (user_id), () );Здесь считаются:
Главные правила:
GROUPING SETSперечисляет наборы колонок для группировки;()означает общий итог;NULL;NULLиз данных и итоговыйNULLнужно различать черезGROUPING();GROUPING(col) = 1означает, что колонка свернута;GROUPING(col) = 0означает, что колонка участвует в группировке;ROLLUPподходит для иерархий;CUBEстроит все комбинации измерений;row_levelи стабильнымORDER BY.Короткий вывод
GROUPING SETSнужен, когда отчёт требует несколько срезов одной и той же метрики.Вместо нескольких похожих запросов:
SELECT ... GROUP BY status UNION ALL SELECT ... GROUP BY user_id UNION ALL SELECT ...можно написать один запрос:
SELECT status, user_id, SUM(amount) AS total FROM orders GROUP BY GROUPING SETS ( (status), (user_id), () );Это делает SQL короче, безопаснее и понятнее.
Главная мысль:
Но вместе с этим нужно аккуратно проектировать результат.
Не оставляйте downstream-коду загадочные
NULL. ДобавляйтеGROUPING(),row_level, понятные подписи и стабильную сортировку. Тогда отчёт будет не только компактным, но и безопасным для реального использования.