GROUP BY в SQL: группировка по одному и нескольким полям
Группировка в SQL на учебной базе: GROUP BY по нескольким полям, по дате и выражению, порядок выполнения, ошибка must appear in the GROUP BY clause, NULL, DISTINCT и PARTITION BY.
Содержание статьи
GROUP BY собирает строки с одинаковым значением поля в одну строку результата, а агрегатная функция считает по каждой такой группе: SELECT channel, count(*) FROM users GROUP BY channel возвращает по строке на канал. Чтобы сгруппировать по нескольким полям, их перечисляют через запятую: GROUP BY channel, device даёт строку на каждую пару «канал и устройство». Группировать можно и по дате, и по любому выражению. Ниже — как группировка в SQL работает на учебной базе сервиса «Маяк» (4 613 пользователей, 35 341 событие), как исправить ошибку must appear in the GROUP BY clause и четыре места, где запрос выполняется, а результат читают неверно: совпавший алиас, пустые значения, исчезнувшая группа и неполный день. Запросы проверены в PostgreSQL 14 и DuckDB.
Что делает GROUP BY: одна строка на группу
В таблице users одна строка — один пользователь. Запрос ниже делит 4 613 строк на группы по значению channel и возвращает по одной строке на группу: канал и число пользователей в нём. Каналов четыре, значит, и строк четыре, а сумма по ним равна размеру таблицы. Так стоит проверять любую группировку с count(*): сумма по группам должна совпасть с числом строк, прошедших WHERE.
После GROUP BY в SELECT остаются два вида выражений: поля, по которым идёт группировка (и выражения от них), и агрегаты — count, sum, avg, min, max. Что считает каждый из них и почему count(*), count(column) и count(DISTINCT column) дают три разных числа, разобрано в материале об агрегации. Здесь речь о том, как устроены сами группы.
Порядка строк GROUP BY не обещает. Если результат должен идти по убыванию, об этом говорит ORDER BY.
| channel | users |
|---|---|
| organic | 1 747 |
| referral | 1 037 |
| paid_search | 1 015 |
| partner | 814 |
SELECT
channel,
count(*) AS users
FROM users
GROUP BY channel
ORDER BY users DESC;Как сгруппировать по нескольким полям
Поля перечисляют через запятую, и группой становится каждая встретившаяся комбинация значений. GROUP BY channel, device даёт восемь строк: четыре канала на два устройства. Порядок полей в GROUP BY на результат не влияет: GROUP BY device, channel вернёт те же восемь групп.
Каждое добавленное поле меняет зерно результата — то, что означает одна строка. Было «канал», стало «канал и устройство». Добавьте страну, и строк будет 40: у страны пять значений, считая пустое. Сумма по группам при этом остаётся 4 613: строки переходят из группы в группу, но не теряются и не удваиваются.
На восьми строках видно то, чего не было на четырёх. У paid_search мобильных регистраций почти вдвое больше, чем десктопных, 670 против 345. В остальных каналах впереди десктоп.
| channel | desktop | mobile |
|---|---|---|
| organic | 953 | 794 |
| paid_search | 345 | 670 |
| partner | 486 | 328 |
| referral | 600 | 437 |
SELECT
channel,
device,
count(*) AS users
FROM users
GROUP BY channel, device
ORDER BY channel, device;Группировка по дате и по выражению
В GROUP BY может стоять выражение, а не только колонка. Самый частый случай — дата, усечённая до периода: date_trunc('month', signup_date) превращает 90 дней регистраций в три месяца — 1 042, 1 676 и 1 895 человек.
Без усечения группировка по времени бесполезна. В таблице events время записано с точностью до минуты, и GROUP BY event_time раскладывает 35 341 событие на 26 987 групп. GROUP BY CAST(event_time AS DATE) даёт 91 строку, по одной на день.
Последняя из этих строк — ловушка. Выгрузка обрывается 30 августа в 12:59, и за этот день в группе 226 событий против 588 накануне. Группировка посчитала то, что есть, а падением это сделает читатель графика. Неполный период отрезают в WHERE или подписывают.
С count(*) группы по дням складываются в общее число событий, с уникальными пользователями — нет. count(DISTINCT user_id) в той же группировке даёт дневную аудиторию: 29 августа это 530 человек на 588 событий. За неделю с 3 по 9 августа сумма таких дневных чисел равна 3 190, а разных пользователей за неделю 1 851: один человек попадает в группу каждого дня, когда он был активен.
Выражение может быть любым. extract(isodow FROM signup_date) группирует по дню недели: на графике ниже 283 регистрации по воскресеньям и 828 по четвергам. CASE WHEN … THEN 'выходные' ELSE 'будни' END группирует по сегментам, которые вы задали сами.
Учебная база, регистрации с 1 июня по 29 августа 2026 года. Воскресений в периоде 12, остальных дней недели по 13.
SELECT
CAST(date_trunc('month', signup_date) AS DATE) AS month,
count(*) AS users
FROM users
GROUP BY month
ORDER BY month;В каком порядке выполняется запрос с GROUP BY
Запрос выполняется не в том порядке, в каком записан. Из этого порядка следуют два правила группировки и одно исключение.
Первое: WHERE работает до группировки и отбирает строки, HAVING — после и отбирает группы. Запрос «каналы, которые дали в августе не меньше 500 регистраций» использует оба условия. WHERE оставляет 1 895 августовских строк, HAVING — две группы из четырёх: organic (672) и paid_search (533).
Второе: на шаге WHERE агрегата ещё не существует. Условие WHERE count(*) >= 500 PostgreSQL отклоняет с текстом aggregate functions are not allowed in WHERE.
Исключение — алиас. Он появляется только в SELECT, но в GROUP BY и PostgreSQL, и DuckDB его принимают: GROUP BY month из запроса выше работает. Это расширение, а не общее правило: SQL Server алиас из SELECT в GROUP BY не принимает, там выражение повторяют целиком. В PostgreSQL и DuckDB работает и номер колонки, GROUP BY 1. Оба сокращения удобны, и оба умеют ломаться без ошибки — об этом следующий раздел.
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMITWHERE видит отдельные строки, HAVING — уже собранные группы, ORDER BY — готовый результат вместе с алиасами.
SELECT
channel,
count(*) AS users
FROM users
WHERE signup_date >= DATE '2026-08-01'
GROUP BY channel
HAVING count(*) >= 500
ORDER BY users DESC;Алиас совпал с именем колонки: 90 строк вместо трёх
Аналитик считает регистрации по месяцам и оставляет колонке привычное имя: CAST(date_trunc('month', signup_date) AS DATE) AS signup_date, а ниже пишет GROUP BY signup_date. Запрос выполняется и возвращает 90 строк. Дат в них всего три: 1 июня повторяется 30 раз, 1 июля — 31, 1 августа — 29, и в каждой строке от 14 до 100 регистраций.
Причина в правиле разбора имён. Когда имя в GROUP BY совпадает и с колонкой таблицы, и с алиасом из SELECT, обе базы выбирают колонку таблицы. Группировка прошла по исходной дате, то есть по дням, а усечённый месяц стал подписью каждой дневной группы. Ошибки нет: выражение от сгруппированной колонки в SELECT разрешено.
Исправлений три: дать алиасу имя, которого нет в таблице (AS month), повторить выражение в GROUP BY или написать GROUP BY 1. Каждое возвращает три строки. У GROUP BY 1 своя цена: переставьте колонки в SELECT, и группировка без предупреждения переедет на другое поле.
SELECT
count(*) AS result_rows, -- 90
count(DISTINCT signup_date) AS months -- 3
FROM (
SELECT
CAST(date_trunc('month', signup_date) AS DATE) AS signup_date,
count(*) AS users
FROM users
GROUP BY signup_date
) AS t;Ошибка column must appear in the GROUP BY clause
Запрос SELECT channel, device, count(*) FROM users GROUP BY channel просит одну строку на канал, но устройств в каждом канале два, и базе неоткуда знать, какое показать. PostgreSQL отвечает: column "users.device" must appear in the GROUP BY clause or be used in an aggregate function. DuckDB — почти теми же словами: column "device" must appear in the GROUP BY clause or must be part of an aggregate function.
Исправление зависит от вопроса. Нужна разбивка по устройствам — добавьте device в GROUP BY, строк станет восемь. Нужна одна строка на канал — оберните поле в агрегат: count(DISTINCT device) вернёт 2 для каждого канала, min(device) — desktop. Колонка попала в SELECT случайно — уберите её.
Есть одно исключение. PostgreSQL разрешает группировать по первичному ключу таблицы и выводить остальные её колонки без агрегата: ключ определяет их однозначно. На копии users с объявленным ключом запрос SELECT u.user_id, u.channel, count(*) … GROUP BY u.user_id в PostgreSQL выполняется, DuckDB на нём возвращает ту же ошибку. В учебной базе ключи не объявлены, поэтому надёжнее перечислять поля.
| Что нужно | Исправление | Строк в результате |
|---|---|---|
| разбивка по каналу и устройству | GROUP BY channel, device | 8 |
| одна строка на канал, устройство — сводкой | count(DISTINCT device) или min(device) | 4 |
| одна строка на канал, устройство не нужно | убрать device из SELECT | 4 |
GROUP BY и NULL: пустые значения собираются в одну группу
У 55 пользователей не заполнена страна. Сравнение NULL = NULL истины не даёт, но GROUP BY считает все пустые значения одинаковыми. GROUP BY country возвращает пять строк: четыре страны и строку с NULL на 55 человек.
Агрегаты внутри этой группы расходятся: count(*) даёт 55, а count(country) — 0, потому что считает только заполненные значения. Отчёт с count(country) в колонке «пользователей» покажет пустую страну с нулём. Чтобы группа читалась, ей дают имя — COALESCE(country, 'не указана') и в SELECT, и в GROUP BY.
В pandas то же место устроено наоборот: groupby по умолчанию выбрасывает строки с пропуском в ключе. Получается четыре группы вместо пяти и 4 558 пользователей вместо 4 613, без предупреждения. Группу возвращает параметр dropna=False.
| country | users | filled |
|---|---|---|
| RU | 2 715 | 2 715 |
| KZ | 912 | 912 |
| AM | 517 | 517 |
| BY | 414 | 414 |
| NULL | 55 | 0 |
SELECT
country,
count(*) AS users, -- NULL-группа: 55
count(country) AS filled -- NULL-группа: 0
FROM users
GROUP BY country
ORDER BY users DESC;import pandas as pd
# те же пять групп, что вернул SQL-запрос выше
by_country = {"RU": 2715, "KZ": 912, "AM": 517, "BY": 414, None: 55}
users = pd.DataFrame({"country": [c for c, n in by_country.items() for _ in range(n)]})
default = users.groupby("country").size()
print(len(default), default.sum()) # 4 4558 — 55 строк с пропуском исчезли
kept = users.groupby("country", dropna=False).size()
print(len(kept), kept.sum()) # 5 4613Группа без строк в результат не попадает
GROUP BY строит группы из строк, которые есть. Комбинации, которой в данных нет, в результате не будет — даже нулём. Регистрации по месяцам и каналам дают 11 строк, а не 12: платный поиск запустили 1 июля, и строки «июнь, paid_search» не существует.
В таблице такой пропуск легко не заметить. На графике линия канала начнётся с июля, а в сводной таблице на месте июня окажется пустая ячейка, которую прочитают как «нет данных». Если ноль нужен явно, сначала строят сетку всех комбинаций через CROSS JOIN, затем присоединяют к ней посчитанные группы через LEFT JOIN и заменяют NULL на 0.
WITH months AS (
SELECT DISTINCT CAST(date_trunc('month', signup_date) AS DATE) AS month FROM users
),
channels AS (
SELECT DISTINCT channel FROM users
),
fact AS (
SELECT
CAST(date_trunc('month', signup_date) AS DATE) AS month,
channel,
count(*) AS users
FROM users
GROUP BY month, channel -- 11 строк
)
SELECT m.month, c.channel, COALESCE(f.users, 0) AS users
FROM months m
CROSS JOIN channels c
LEFT JOIN fact f ON f.month = m.month AND f.channel = c.channel
ORDER BY m.month, c.channel;Чем GROUP BY отличается от DISTINCT и PARTITION BY
Три конструкции работают с одними и теми же группами, а различаются формой результата. SELECT DISTINCT channel, device и GROUP BY channel, device без агрегатов возвращают одни и те же восемь строк. Разница появляется, когда рядом нужно число: DISTINCT его не посчитает, GROUP BY посчитает любое.
PARTITION BY внутри оконной функции делит строки на те же группы, но не схлопывает их. count(*) OVER (PARTITION BY channel) возвращает все 4 613 строк, и в каждой стоит размер её канала — одно из четырёх значений. Окно берут, когда итог группы должен стоять рядом с отдельной строкой: доля платежа в выручке канала, номер события у пользователя.
| DISTINCT | GROUP BY | OVER (PARTITION BY) | |
|---|---|---|---|
| Строк в результате | 4 | 4 | 4 613 |
| Число по группе | нет | любой агрегат | агрегат в каждой строке |
| Что остаётся от строки | перечисленные поля | поля группировки и агрегаты | все колонки |
Частые вопросы
Можно ли написать GROUP BY без агрегатной функции?
Можно: запрос вернёт уникальные комбинации полей, как SELECT DISTINCT. Обратное тоже работает: агрегат без GROUP BY считает всю таблицу как одну группу.
Можно ли группировать по полю, которого нет в SELECT?
Да. SELECT count(*) FROM users GROUP BY channel вернёт четыре числа без подписей: запрос выполнится, но прочитать результат будет нельзя.
Как добавить к группам строку «Итого»?
GROUP BY ROLLUP (channel) возвращает пять строк: четыре канала и итоговую, где вместо канала NULL, а в счётчике 4 613.
Что такое GROUP BY ALL?
В DuckDB это сокращение: группировать по всем колонкам SELECT, в которых нет агрегата. В PostgreSQL 14 такой записи нет: GROUP BY ALL без списка полей там заканчивается синтаксической ошибкой. В переносимом запросе поля перечисляют.
Чем GROUP BY отличается от ORDER BY?
GROUP BY уменьшает число строк, собирая их в группы. ORDER BY число строк не меняет и только расставляет их по порядку. В одном запросе они обычно стоят вместе.
Практика на учебной базе
Три задачи для песочницы симулятора. База та же, числа должны совпасть до единицы.
Первая. Сгруппируйте платежи по тарифу и типу платежа, посчитайте число платежей и выручку. Ответ: шесть строк; самая крупная — тариф basic, первый платёж: 514 платежей на $9 766. Сумма по всем группам — $30 639.
Вторая. Разделите регистрации на будни и выходные через CASE по extract(isodow FROM signup_date) и добавьте число дней в каждой группе. Ответ: будни — 3 980 регистраций за 65 дней, выходные — 633 за 25.
Третья. Сгруппируйте пользователей по стране и устройству. Сколько строк получится и сколько из них с пустой страной? Ответ: десять строк, две с NULL — 28 пользователей на desktop и 27 на mobile.
Итог
Группировка задаёт, что означает одна строка результата. Назовите это до запроса, перечислите в GROUP BY ровно эти поля и проверьте три вещи: число строк совпало с ожидаемым числом групп, сумма count(*) по группам сошлась с числом исходных строк, пустые значения и отсутствующие комбинации не потерялись.
В симуляторе SQL-аналитика группировке и агрегатам отведена третья глава: те же сводки по каналам и тарифам собираются в заданиях с автоматической проверкой.
Материалы по теме
Агрегация в SQL: COUNT, SUM, GROUP BY, HAVING и условные метрики
Большой разбор агрегации в SQL для аналитика: чем COUNT(*) отличается от COUNT(column), когда нужен DISTINCT, а когда GROUP BY, почему фильтр по агрегату уходит в HAVING и как считать несколько метрик одним запросом через CASE и FILTER.

PARTITION BY и OVER в SQL: окно, группы и отличие от GROUP BY
PARTITION BY и OVER в SQL: окно и отличие от GROUP BY, доля от итога, накопительный итог, рамки ROWS и RANGE, фильтр по оконной функции.

DISTINCT в SQL: уникальные строки, COUNT(DISTINCT) и DISTINCT ON
DISTINCT в SQL убирает повторяющиеся строки. Как работают SELECT DISTINCT по нескольким столбцам, COUNT(DISTINCT), DISTINCT ON и чем он отличается от GROUP BY.