Любой SQL-запрос делится на две группы: схлопывает строки или нет. GROUP BY — главный инструмент схлопывания. Без него ты не посчитаешь ни DAU, ни MAU, ни средний чек, ни конверсию. С ним — частая причина багов: пропустил колонку в GROUP BY, попробовал агрегат в WHERE, не понял разницу с DISTINCT.
Эта статья — без воды о том, как работает GROUP BY на самом деле, чем отличается от HAVING, и какие фишки знают только те кто пишет SQL ежедневно.
Что делает GROUP BY на самом деле
GROUP BY группирует строки с одинаковыми значениями указанных колонок и применяет агрегат (COUNT, SUM, AVG, MIN, MAX) к каждой группе.
SELECT country, COUNT(*) AS users_cnt
FROM users
GROUP BY country;
Что происходит внутри:
- БД читает все строки таблицы
- Сортирует или хэширует по
country - Для каждой группы (одна страна) считает
COUNT(*) - Возвращает по одной строке на страну
Правило: каждая колонка в SELECT — либо в GROUP BY, либо под агрегатом
-- ❌ Ошибка
SELECT country, city, COUNT(*) FROM users GROUP BY country;
-- city не в GROUP BY и не агрегирован → какой city показать для каждой страны?
-- ✅ Правильно
SELECT country, city, COUNT(*) FROM users GROUP BY country, city;
В PostgreSQL/Standard SQL это строгая ошибка. В MySQL — может молча отдать любой city из группы (часто НЕ тот что ожидаешь).
HAVING vs WHERE — главная путаница
WHERE фильтрует строки до агрегации.
HAVING фильтрует группы после агрегации.
-- Страны где больше 1000 ПЛАТЯЩИХ юзеров
SELECT country, COUNT(*) AS paying_users
FROM users
WHERE is_paying = TRUE -- фильтр строк ДО GROUP BY
GROUP BY country
HAVING COUNT(*) > 1000; -- фильтр групп ПОСЛЕ агрегации
Можно ли заменить HAVING на WHERE? Нет. COUNT(*) существует только после GROUP BY — в WHERE его ещё нет.
Можно ли заменить WHERE на HAVING? Технически да, но плохо для производительности — фильтруешь все строки после группировки.
Условные агрегаты: FILTER (WHERE) vs CASE WHEN
Старый способ:
SELECT
country,
COUNT(*) AS total,
SUM(CASE WHEN is_paying THEN 1 ELSE 0 END) AS paying,
SUM(CASE WHEN amount > 1000 THEN amount ELSE 0 END) AS big_amount
FROM users
GROUP BY country;
Новый и читаемый — FILTER (WHERE ...) (PostgreSQL, ClickHouse, BigQuery, Snowflake):
SELECT
country,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE is_paying) AS paying,
SUM(amount) FILTER (WHERE amount > 1000) AS big_amount
FROM users
GROUP BY country;
Короче, чище, точно так же работает. Если БД поддерживает FILTER (WHERE) — используй вместо CASE.
GROUPING SETS, ROLLUP, CUBE — несколько срезов одним запросом
Хочешь увидеть и общий итог, и группировку по стране, и по стране+городу, в одном результате?
SELECT country, city, SUM(revenue)
FROM events
GROUP BY GROUPING SETS (
(country, city), -- разрез по стране+городу
(country), -- разрез только по стране
() -- общий итог
);
Удобный shortcut — ROLLUP:
GROUP BY ROLLUP (country, city)
-- = GROUPING SETS ((country, city), (country), ())
И CUBE — все возможные комбинации:
GROUP BY CUBE (country, city, device)
-- 8 разрезов: (), (country), (city), (device), (country, city), (country, device), (city, device), (country, city, device)
Используется для сводных отчётов где нужны множественные подытоги.
Типичные ошибки
NULL и COUNT — три разных результата
COUNT(*) -- все строки, включая NULL
COUNT(column) -- только non-NULL значения column
COUNT(DISTINCT col) -- уникальные non-NULL значения
Если у тебя в users.email есть NULL'ы:
COUNT(*)= 1000COUNT(email)= 950 (50 NULL'ов)COUNT(DISTINCT email)= 920 (плюс 30 дубликатов среди non-NULL)
На собеседовании любят гонять по этим различиям.
AVG игнорирует NULL
AVG(score) -- считает только строки с не-NULL score
Если хочешь NULL'ы как 0:
AVG(COALESCE(score, 0))
-- или
SUM(COALESCE(score, 0)) / COUNT(*)
SELECT DISTINCT vs GROUP BY
SELECT DISTINCT country FROM users;
-- эквивалентно
SELECT country FROM users GROUP BY country;
Когда без агрегатов — оба одинаково. Если хотя бы один агрегат — нужен GROUP BY (через DISTINCT не получится).
Что спросят на собеседовании
Задача-классика: топ-3 в каждой категории
-- Подход через оконную функцию (правильный)
SELECT category, name, revenue
FROM (
SELECT category, name, revenue,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) AS rn
FROM products
) ranked
WHERE rn <= 3;
Через GROUP BY это решается сложно — нужен JOIN с подзапросом. Знать оба подхода = плюс.
Задача: процент от общего
-- Доля каждой страны от общей выручки
SELECT
country,
SUM(revenue) AS country_revenue,
SUM(revenue) * 100.0 / SUM(SUM(revenue)) OVER () AS pct
FROM events
GROUP BY country;
Хитрость — оконная функция SUM(SUM(...)) OVER () после GROUP BY даёт общую сумму. Без оконных пришлось бы делать subquery.
Задача: только группы где все строки удовлетворяют условию
-- Категории, где ВСЕ товары дороже 1000
SELECT category
FROM products
GROUP BY category
HAVING MIN(price) > 1000;
-- эквивалент: SUM(CASE WHEN price <= 1000 THEN 1 ELSE 0 END) = 0
Связанные материалы
- SQL-тренажёр: задачи на агрегацию — 325 задач с автопроверкой
- Оконные функции SQL — следующий уровень после агрегаций
- SQL JOIN — все типы — часто комбинируется с GROUP BY
- CTE и рекурсивные запросы — как разложить сложную агрегацию на шаги
- Конспекты SQL — теория с примерами
Открой SQL-тренажёр, найди задачу на «топ-N в группе» — реши через GROUP BY + JOIN, потом через оконную функцию. Поймёшь, почему оконные — революция.