SQLGROUP BYHAVINGагрегациясобеседование

GROUP BY и HAVING в SQL: правильные агрегации

2026-04-25 11 мин

Любой 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;

Что происходит внутри:

Правило: каждая колонка в 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 из группы (часто НЕ тот что ожидаешь).

Mental model
«Если бы строки этой группы были одной — какое значение здесь должно быть?» Если ответ «любое из них» — нужен агрегат. Если «одно конкретное» — добавь в GROUP BY. Если «не имеет смысла» — убери из SELECT.


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'ы:

На собеседовании любят гонять по этим различиям.

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-тренажёр, найди задачу на «топ-N в группе» — реши через GROUP BY + JOIN, потом через оконную функцию. Поймёшь, почему оконные — революция.

Закрепи на задачах
325 SQL-задач: GROUP BY, HAVING, FILTER, оконные функции. Реальные собесные кейсы. Первые 5 бесплатно.
Открыть SQL-тренажёр →