Все материалы
Продуктовая аналитикагайдстарт

GROUP BY в SQL: группировка по одному и нескольким полям

Группировка в SQL на учебной базе: GROUP BY по нескольким полям, по дате и выражению, порядок выполнения, ошибка must appear in the GROUP BY clause, NULL, DISTINCT и PARTITION BY.

КейсПрактика14 июля 2026 г.обновлено 2 октября 2026 г.13 мин

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.

Результат: четыре группы, в сумме 4 613
channelusers
organic1 747
referral1 037
paid_search1 015
partner814
Группировка по одному полю: пользователи по каналам
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. В остальных каналах впереди десктоп.

Те же восемь групп: канал по строкам, устройство по колонкам
channeldesktopmobile
organic953794
paid_search345670
partner486328
referral600437
Группировка по двум полям: канал и устройство
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 → LIMIT

WHERE видит отдельные строки, HAVING — уже собранные группы, ORDER BY — готовый результат вместе с алиасами.

WHERE отбирает строки, HAVING — группы
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, device8
одна строка на канал, устройство — сводкойcount(DISTINCT device) или min(device)4
одна строка на канал, устройство не нужноубрать device из SELECT4

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.

Результат SQL-запроса: пятая группа — пустая страна
countryusersfilled
RU2 7152 715
KZ912912
AM517517
BY414414
NULL550
Группа с NULL: count(*) и count(country) расходятся
SELECT
  country,
  count(*)       AS users,    -- NULL-группа: 55
  count(country) AS filled    -- NULL-группа: 0
FROM users
GROUP BY country
ORDER BY users DESC;
pythonpandas: groupby без dropna=False теряет группу с пропуском
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.

Сетка «месяц и канал»: 12 строк, июньский paid_search — ноль
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 строк, и в каждой стоит размер её канала — одно из четырёх значений. Окно берут, когда итог группы должен стоять рядом с отдельной строкой: доля платежа в выручке канала, номер события у пользователя.

Одни и те же группы по channel, три формы результата
DISTINCTGROUP BYOVER (PARTITION BY)
Строк в результате444 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-аналитика группировке и агрегатам отведена третья глава: те же сводки по каналам и тарифам собираются в заданиях с автоматической проверкой.

Продолжить чтение
Вся библиотека