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 citySUM() — сумма значений.
SELECT category, SUM(amount) AS total_revenue
FROM sales
GROUP BY categoryAVG() — среднее арифметическое.
SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY departmentMIN() и MAX() — минимальное и максимальное значение.
SELECT user_id, MIN(order_date) AS first_order, MAX(order_date) AS last_order
FROM orders
GROUP BY user_idCOUNT(DISTINCT) — количество уникальных значений. Это важная конструкция, которую часто забывают.
SELECT DATE, COUNT(DISTINCT user_id) AS dau
FROM user_activity
GROUP BY DATECOUNT(*) считает все строки, включая дубликаты. 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 DESCHAVING: фильтрация групп
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 выполняет запрос, отличается от порядка, в котором вы его пишете.
Порядок выполнения:
- FROM — определяет таблицу (и джойны, если есть)
- WHERE — фильтрует строки
- GROUP BY — группирует оставшиеся строки
- HAVING — фильтрует группы
- SELECT — вычисляет выражения и агрегаты для вывода
- ORDER BY — сортирует результат
- 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_weekNULL в 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. Это различие любят проверять на собеседованиях.
Что спрашивают на собеседованиях
Чем отличается WHERE от HAVING? — WHERE фильтрует строки до группировки, HAVING — группы после группировки. WHERE не может содержать агрегатные функции.
В каком порядке SQL выполняет запрос? — FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT. Порядок написания и порядок выполнения — разные вещи.
Чем отличается COUNT(*) от COUNT(column) от COUNT(DISTINCT column)? — COUNT(*) считает все строки. COUNT(column) — строки, где column не NULL. COUNT(DISTINCT column) — уникальные не-NULL значения.
Напишите запрос: найти пользователей, которые сделали более 5 покупок на сумму больше 10 000. Решение — GROUP BY user_id + HAVING COUNT(*) > 5 AND SUM(amount) > 10000.
Можно ли использовать алиас из 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(*), или убедиться, что джойн не создаёт лишних строк.