sqlpostgresqlgrouping-setsaggregation

SQL GROUPING SETS: Subtotals and Grand Totals in One Query

Use GROUPING SETS to compute several grouping levels in a single pass and tell subtotal rows apart from real NULLs.

9 min czytaniaReferencesql · postgresql · grouping-sets · aggregation · group-by
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 может означать две разные вещи:

  1. колонка не участвует в текущем уровне группировки;
  2. в исходных данных действительно было значение 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)
()

То есть:

  1. детализация по отделу и менеджеру;
  2. итог по отделу;
  3. общий итог.

Это удобно для иерархических отчётов.

Например:

отдел → менеджер → общий итог
страна → город → общий итог
год → месяц → общий итог

Пример 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, понятные подписи и стабильную сортировку. Тогда отчёт будет не только компактным, но и безопасным для реального использования.

Ćwicz na prawdziwych zadaniach

Rozwiązuj zadania w trenerze SQL z natychmiastową oceną i podpowiedziami.

Otwórz trener