GROUP BY и HAVING в SQL

Зачем аналитику GROUP BY

GROUP BY — одна из первых конструкций, которую спрашивают на собеседовании по SQL. Без неё невозможно посчитать ни одну агрегированную метрику: выручку по категориям, количество заказов по пользователям, среднее время сессии по дням. Если вы умеете писать SELECT и WHERE, но не уверены в GROUP BY — на техническом интервью будет сложно.

GROUP BY сворачивает несколько строк в одну по указанному столбцу. Все строки с одинаковым значением группируются вместе, и к каждой группе применяется агрегатная функция.

SELECT
    department,
    COUNT(*) AS employee_count
FROM employees
GROUP BY department

Результат: одна строка на каждый отдел, рядом — количество сотрудников в нём.

Агрегатные функции

GROUP BY без агрегатной функции бесполезен. Вот пять функций, которые вы будете использовать постоянно:

COUNT(*) — количество строк в группе.

SELECT city, COUNT(*) AS order_count
FROM orders
GROUP BY city

SUM() — сумма значений.

SELECT category, SUM(amount) AS total_revenue
FROM sales
GROUP BY category

AVG() — среднее арифметическое.

SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department

MIN() и MAX() — минимальное и максимальное значение.

SELECT user_id, MIN(order_date) AS first_order, MAX(order_date) AS last_order
FROM orders
GROUP BY user_id

COUNT(DISTINCT) — количество уникальных значений. Это важная конструкция, которую часто забывают.

SELECT DATE, COUNT(DISTINCT user_id) AS dau
FROM user_activity
GROUP BY DATE

COUNT(*) считает все строки, включая дубликаты. COUNT(DISTINCT user_id) считает уникальных пользователей. На собеседованиях это спрашивают почти гарантированно: «Чем отличаются COUNT(*) и COUNT(DISTINCT)?».

GROUP BY по нескольким столбцам

Группировать можно не по одному столбцу, а по нескольким — каждая уникальная комбинация значений станет отдельной группой.

SELECT
    category,
    region,
    SUM(amount) AS total_revenue
FROM sales
GROUP BY category, region
ORDER BY category, total_revenue DESC

Результат: выручка для каждой пары «категория + регион». Если у вас 5 категорий и 3 региона, получится до 15 строк.

Типичная задача на собеседовании: «Посчитайте количество заказов по месяцам и городам». Решение:

SELECT
    DATE_TRUNC('month', order_date) AS month,
    city,
    COUNT(*) AS order_count
FROM orders
GROUP BY DATE_TRUNC('month', order_date), city
ORDER BY month, order_count DESC

HAVING: фильтрация групп

WHERE фильтрует строки до группировки. HAVING фильтрует группы после группировки. Это принципиальное различие, которое нужно понимать чётко.

WHERE — работает с исходными строками:

SELECT
    category,
    SUM(amount) AS total_revenue
FROM sales
WHERE amount > 0
GROUP BY category

Здесь WHERE убирает строки с нулевой или отрицательной суммой до того, как группировка вычислит итоги.

HAVING — работает с результатом агрегации:

SELECT
    category,
    SUM(amount) AS total_revenue
FROM sales
GROUP BY category
HAVING SUM(amount) > 100000

Здесь HAVING оставляет только те категории, у которых суммарная выручка превышает 100 000. Это невозможно сделать через WHERE, потому что на этапе WHERE агрегат ещё не вычислен.

Комбинация WHERE + HAVING:

SELECT
    user_id,
    COUNT(*) AS order_count
FROM orders
WHERE order_date >= '2025-01-01'
GROUP BY user_id
HAVING COUNT(*) >= 3

Сначала WHERE оставляет только заказы 2025 года. Затем GROUP BY группирует по пользователям. Наконец, HAVING оставляет только тех, у кого 3 и более заказов.

Порядок выполнения SQL-запроса

Это одна из самых частых теоретических тем на собеседовании. Порядок, в котором SQL выполняет запрос, отличается от порядка, в котором вы его пишете.

Порядок выполнения:

  1. FROM — определяет таблицу (и джойны, если есть)
  2. WHERE — фильтрует строки
  3. GROUP BY — группирует оставшиеся строки
  4. HAVING — фильтрует группы
  5. SELECT — вычисляет выражения и агрегаты для вывода
  6. ORDER BY — сортирует результат
  7. LIMIT — обрезает количество строк

Из этого порядка следует несколько важных правил:

  • В WHERE нельзя использовать агрегатные функции — группировка ещё не произошла.
  • В HAVING нельзя ссылаться на алиас из SELECT (в стандартном SQL; PostgreSQL это допускает как расширение).
  • ORDER BY может использовать алиасы из SELECT — он выполняется после.
  • В SELECT при наличии GROUP BY можно указывать только столбцы из GROUP BY и агрегатные функции. Всё остальное — ошибка.

Последнее правило — частый источник ошибок. Запрос SELECT user_id, name, COUNT(*) FROM orders GROUP BY user_id не сработает, потому что name не входит в GROUP BY и не обёрнут в агрегатную функцию.

Практические примеры

Выручка по категориям за последний месяц

SELECT
    p.category,
    COUNT(DISTINCT o.order_id) AS orders,
    SUM(o.amount) AS revenue,
    ROUND(AVG(o.amount), 2) AS avg_check
FROM orders o
JOIN products p ON o.product_id = p.product_id
WHERE o.order_date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY p.category
ORDER BY revenue DESC

Пользователи с 3+ заказами

SELECT
    user_id,
    COUNT(*) AS order_count,
    SUM(amount) AS total_spent
FROM orders
GROUP BY user_id
HAVING COUNT(*) >= 3
ORDER BY total_spent DESC

Этот паттерн — GROUP BY + HAVING — встречается на собеседованиях постоянно. Варианты формулировок: «пользователи, купившие более N раз», «города с выручкой больше X», «дни, когда DAU превышал Y».

Monthly Active Users (MAU)

SELECT
    DATE_TRUNC('month', event_date) AS month,
    COUNT(DISTINCT user_id) AS mau
FROM user_activity
GROUP BY DATE_TRUNC('month', event_date)
ORDER BY month

Обратите внимание на COUNT(DISTINCT) — без него вы посчитаете количество событий, а не уникальных пользователей. Это та ошибка, которую интервьюеры ожидают увидеть.

Средний чек по дням недели

SELECT
    EXTRACT(DOW FROM order_date) AS day_of_week,
    COUNT(*) AS orders,
    ROUND(AVG(amount), 2) AS avg_check
FROM orders
GROUP BY EXTRACT(DOW FROM order_date)
ORDER BY day_of_week

NULL в GROUP BY

GROUP BY объединяет все NULL-значения в одну группу. Это отличается от поведения NULL в других контекстах SQL, где NULL != NULL.

SELECT
    region,
    COUNT(*) AS user_count
FROM users
GROUP BY region

Если у части пользователей region = NULL, они попадут в отдельную группу. В результате появится строка, где region пуст, а user_count покажет количество таких пользователей.

Агрегатные функции (кроме COUNT(*)) игнорируют NULL. То есть AVG(salary) не учитывает строки, где salary = NULL — ни в числителе, ни в знаменателе. COUNT(*) считает все строки, а COUNT(column) считает только строки, где column IS NOT NULL. Это различие любят проверять на собеседованиях.

Что спрашивают на собеседованиях

  1. Чем отличается WHERE от HAVING? — WHERE фильтрует строки до группировки, HAVING — группы после группировки. WHERE не может содержать агрегатные функции.

  2. В каком порядке SQL выполняет запрос? — FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT. Порядок написания и порядок выполнения — разные вещи.

  3. Чем отличается COUNT(*) от COUNT(column) от COUNT(DISTINCT column)? — COUNT(*) считает все строки. COUNT(column) — строки, где column не NULL. COUNT(DISTINCT column) — уникальные не-NULL значения.

  4. Напишите запрос: найти пользователей, которые сделали более 5 покупок на сумму больше 10 000. Решение — GROUP BY user_id + HAVING COUNT(*) > 5 AND SUM(amount) > 10000.

  5. Можно ли использовать алиас из SELECT в HAVING? — В стандартном SQL нельзя, потому что HAVING выполняется до SELECT. PostgreSQL и MySQL допускают это как расширение, но на собеседовании лучше дублировать выражение.

Эти вопросы — фундамент. Если GROUP BY, HAVING и агрегатные функции доведены до автоматизма, вы справитесь и с более сложными задачами на оконные функции — они строятся поверх того же принципа группировки.

Как тренироваться

GROUP BY и HAVING нужно не просто понять, а написать руками десятки раз. Тренажёр Карьерник содержит вопросы на агрегатные функции, фильтрацию групп и порядок выполнения запросов — с разборами. Можно тренироваться по 15 минут в день в Telegram, и за неделю тема перестанет вызывать затруднения.

Больше вопросов по SQL — в разделе подготовки к собеседованию. Если готовитесь комплексно, посмотрите примеры вопросов по всем темам.

FAQ

Можно ли использовать GROUP BY без агрегатных функций?

Технически — да. GROUP BY без агрегата работает как SELECT DISTINCT: убирает дублирующиеся строки. Но на практике это запутывает код. Если вам нужны уникальные значения, используйте DISTINCT — это яснее по намерению.

В чём разница между HAVING COUNT(*) > 1 и подзапросом с COUNT?

Результат одинаковый, но HAVING работает в один проход, а подзапрос требует два. Для простых условий HAVING проще и эффективнее. Для сложной логики — например, «пользователи, чей средний чек выше общего среднего» — подзапрос может быть понятнее.

Как работает GROUP BY с JOIN?

Точно так же. Сначала выполняется FROM с JOIN, получается промежуточная таблица, и уже к ней применяется GROUP BY. Частая ошибка: если JOIN даёт дубликаты, агрегаты будут считать эти дубликаты. Решение — COUNT(DISTINCT) вместо COUNT(*), или убедиться, что джойн не создаёт лишних строк.