Агрегатные функции, GROUP BY и HAVING
GROUP BY — механизм SQL для группировки строк по одному или нескольким столбцам с последующим вычислением агрегатных значений для каждой группы. Агрегатные функции (COUNT, SUM, AVG, MIN, MAX, STRING_AGG, ARRAY_AGG) вычисляют единственное значение из множества строк группы. HAVING фильтрует уже сформированные группы — в отличие от WHERE, которое фильтрует строки до группировки. Вместе GROUP BY и агрегаты — это SQL-инструментарий для аналитики: подсчёт заказов по пользователям, суммы продаж по месяцам, средний рейтинг по категориям, количество уникальных посетителей — всё это однострочные GROUP BY запросы которые в коде приложения потребовали бы циклов, хеш-таблиц и мегабайт трафика. Понимание разницы WHERE vs HAVING, COUNT(*) vs COUNT(col), и FILTER — основа для написания эффективных аналитических запросов.
Почему это важно: Аналитические и отчётные запросы — ядро большинства backend-приложений. Каждый дашборд, каждый KPI-отчёт, каждая агрегированная метрика — это GROUP BY запрос. Разница между правильным и неправильным подходом огромна: загрузка 1 миллиона строк в приложение и подсчёт там vs один GROUP BY запрос, возвращающий 12 строк. Разница: 500MB трафика и 10 секунд против 1KB и 100мс. Кроме производительности: сложение в коде всегда имеет race conditions (данные могут измениться между двумя SELECT), GROUP BY в транзакции — консистентный снимок. WHERE vs HAVING — фундаментальное различие: WHERE фильтрует строки ДО группировки (может использовать индекс), HAVING фильтрует группы ПОСЛЕ (работает на результате агрегации). Путаница между ними — источник медленных запросов.
Главная идея
Логика выполнения GROUP BY: FROM+JOIN (собираем строки) → WHERE (фильтруем строки по условию) → GROUP BY (разбиваем на группы) → агрегатные функции (вычисляем значения для каждой группы) → HAVING (фильтруем группы по агрегатным условиям) → SELECT (выбираем столбцы из GROUP BY и агрегатов) → ORDER BY → LIMIT. Ключевое правило: в SELECT при использовании GROUP BY можно иметь только столбцы из GROUP BY-списка и агрегатные функции. Попытка SELECT name (неагрегированный, не в GROUP BY) — ошибка. Это математическое требование: одна группа может иметь несколько значений name — какое вернуть? PostgreSQL разрешает неагрегированные столбцы если они функционально зависят от GROUP BY ключей (например, GROUP BY id и SELECT name при уникальном id — работает).
Как это выглядит на практике
- Базовая агрегация: SELECT category, COUNT(*) AS product_count, AVG(price) AS avg_price, MIN(price) AS min_price, MAX(price) AS max_price FROM products GROUP BY category ORDER BY product_count DESC. Каждая строка результата — одна категория, все остальные значения — агрегаты по её товарам.
- HAVING — фильтрация групп: SELECT category, COUNT(*) AS cnt FROM products GROUP BY category HAVING COUNT(*) >= 5. Только категории с 5+ товарами. В отличие от WHERE category = X (фильтрация строк), HAVING фильтрует уже сформированные группы.
- WHERE + GROUP BY + HAVING вместе: SELECT user_id, SUM(total_amount) AS total FROM orders WHERE status = 'completed' AND created_at >= '2024-01-01' GROUP BY user_id HAVING SUM(total_amount) >= 10000 ORDER BY total DESC. WHERE фильтрует строки до группировки (быстрее), HAVING — группы после.
- GROUP BY по дате с DATE_TRUNC: SELECT DATE_TRUNC('month', created_at) AS month, COUNT(*) AS orders, SUM(total_amount) AS revenue FROM orders WHERE created_at >= '2023-01-01' GROUP BY DATE_TRUNC('month', created_at) ORDER BY month. DATE_TRUNC('month', ts) округляет до начала месяца.
- COUNT(*) vs COUNT(col): SELECT COUNT(*) AS total_rows, COUNT(phone) AS has_phone, COUNT(*) - COUNT(phone) AS no_phone FROM users. COUNT(*) — все строки, COUNT(phone) — строки где phone IS NOT NULL. Разница = количество NULL.
- COUNT(DISTINCT col) — уникальные значения: SELECT DATE_TRUNC('day', created_at) AS day, COUNT(DISTINCT user_id) AS unique_buyers FROM orders GROUP BY 1 ORDER BY 1. Количество уникальных покупателей в день (не заказов).
- FILTER — условная агрегация в одном запросе: SELECT DATE_TRUNC('week', created_at) AS week, COUNT(*) AS total_orders, COUNT(*) FILTER (WHERE status = 'completed') AS completed, COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled, SUM(total_amount) FILTER (WHERE status = 'completed') AS completed_revenue FROM orders GROUP BY 1 ORDER BY 1. Вместо нескольких подзапросов — один запрос.
- STRING_AGG и ARRAY_AGG для объединения значений группы: SELECT user_id, STRING_AGG(tag, ', ' ORDER BY tag) AS tags, ARRAY_AGG(DISTINCT category ORDER BY category) AS categories FROM user_interests GROUP BY user_id. STRING_AGG склеивает строки через разделитель, ARRAY_AGG собирает в массив.
- GROUPING SETS — несколько GROUP BY в одном запросе: SELECT region, product_type, SUM(revenue) FROM sales GROUP BY GROUPING SETS ((region, product_type), (region), (product_type), ()). Одновременно: сумма по регион+тип, по региону, по типу, и общая сумма. Эквивалентно 4 запросам с UNION ALL.
- ROLLUP — иерархические subtotals: SELECT year, quarter, month, SUM(revenue) FROM sales GROUP BY ROLLUP (year, quarter, month). Генерирует суммы для: (year, quarter, month), (year, quarter), (year), и общий итог. Удобно для финансовых отчётов.
- Оконные функции vs GROUP BY: GROUP BY сжимает строки до одной на группу, оконные функции добавляют агрегат не убирая строки. SELECT id, amount, SUM(amount) OVER (PARTITION BY user_id) AS user_total FROM orders — каждая строка заказа + сумма всех заказов пользователя. Это следующий уровень после GROUP BY.
- Производительность: GROUP BY на большой таблице создаёт HashAggregate или GroupAggregate node в плане. HashAggregate строит хеш-таблицу в work_mem — если не хватает места, делает spill на диск. Индекс на GROUP BY столбце позволяет GroupAggregate без сортировки и без спилла. Для частых GROUP BY запросов рассмотри материализованные представления (MATERIALIZED VIEW).
Примеры кода
Аналитический отчёт продаж — реальный пример
-- Ежемесячная статистика продаж по категориям
SELECT
DATE_TRUNC('month', o.created_at)::DATE AS month,
p.category,
COUNT(DISTINCT o.id) AS order_count,
COUNT(DISTINCT o.user_id) AS unique_buyers,
SUM(oi.quantity) AS units_sold,
SUM(oi.quantity * oi.price_at_purchase) AS revenue,
AVG(oi.price_at_purchase) AS avg_item_price,
-- Условные агрегаты в одном запросе
COUNT(*) FILTER (WHERE o.status = 'completed') AS completed,
COUNT(*) FILTER (WHERE o.status = 'cancelled') AS cancelled,
ROUND(
COUNT(*) FILTER (WHERE o.status = 'completed') * 100.0
/ NULLIF(COUNT(*), 0), 1
) AS completion_rate_pct
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.created_at >= '2024-01-01'
AND o.created_at < '2025-01-01'
GROUP BY DATE_TRUNC('month', o.created_at), p.category
HAVING SUM(oi.quantity * oi.price_at_purchase) > 0
ORDER BY month, revenue DESC;
FILTER (WHERE ...) позволяет считать разные условия в одном GROUP BY запросе — вместо 4 подзапросов. NULLIF(COUNT(*), 0) защищает от деления на ноль. Один запрос возвращает полную аналитику за год.
WHERE vs HAVING — разница в производительности
-- МЕДЛЕННО: HAVING для условий на исходные строки
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
HAVING user_id > 1000; -- фильтрует после группировки всей таблицы!
-- БЫСТРО: WHERE до группировки (может использовать индекс)
SELECT user_id, COUNT(*) AS cnt
FROM orders
WHERE user_id > 1000 -- фильтрует ДО GROUP BY, использует индекс
GROUP BY user_id;
-- HAVING правильно использовать — для условий на агрегаты
SELECT user_id, COUNT(*) AS order_count, SUM(total_amount) AS total
FROM orders
WHERE created_at >= '2024-01-01' -- WHERE: на строки
GROUP BY user_id
HAVING COUNT(*) >= 3 -- HAVING: на агрегаты
AND SUM(total_amount) >= 5000;
-- EXPLAIN ANALYZE для сравнения планов
EXPLAIN (ANALYZE, BUFFERS)
SELECT user_id, COUNT(*)
FROM orders
WHERE user_id > 1000
GROUP BY user_id;
WHERE выполняется ДО GROUP BY: если на WHERE-столбце есть индекс — PostgreSQL не читает всю таблицу. HAVING выполняется ПОСЛЕ GROUP BY: все строки уже прочитаны и сгруппированы. Правило: условие на исходные столбцы — WHERE, условие на агрегаты (COUNT, SUM...) — HAVING.
Продвинутые агрегаты — реальные паттерны
-- Перцентили и статистика распределения
SELECT
category,
COUNT(*) AS count,
ROUND(AVG(price)::NUMERIC, 2) AS mean_price,
PERCENTILE_CONT(0.5) WITHIN GROUP
(ORDER BY price) AS median_price,
PERCENTILE_CONT(0.95) WITHIN GROUP
(ORDER BY price) AS p95_price,
MIN(price) AS min_price,
MAX(price) AS max_price
FROM products
GROUP BY category
ORDER BY mean_price DESC;
-- Список значений группы через STRING_AGG
SELECT
u.id,
u.full_name,
STRING_AGG(t.name, ', ' ORDER BY t.name) AS tags,
ARRAY_AGG(DISTINCT t.category) AS tag_categories
FROM users u
JOIN user_tags ut ON ut.user_id = u.id
JOIN tags t ON t.id = ut.tag_id
GROUP BY u.id, u.full_name
HAVING COUNT(t.id) >= 3;
-- ROLLUP: суммы по иерархии (год → квартал → месяц)
SELECT
EXTRACT(year FROM created_at) AS year,
EXTRACT(quarter FROM created_at) AS quarter,
EXTRACT(month FROM created_at) AS month,
SUM(total_amount) AS revenue,
GROUPING(EXTRACT(year FROM created_at),
EXTRACT(quarter FROM created_at),
EXTRACT(month FROM created_at)) AS grouping_set
FROM orders
WHERE status = 'completed'
GROUP BY ROLLUP (
EXTRACT(year FROM created_at),
EXTRACT(quarter FROM created_at),
EXTRACT(month FROM created_at)
)
ORDER BY year NULLS LAST, quarter NULLS LAST, month NULLS LAST;
PERCENTILE_CONT — статистические функции для анализа распределения данных. STRING_AGG собирает строки группы с разделителем. ROLLUP генерирует subtotals по иерархии без нескольких запросов с UNION ALL.
Что происходит под капотом
- Логический порядок выполнения GROUP BY запроса: FROM → WHERE → GROUP BY → Агрегатные функции → HAVING → SELECT → ORDER BY → LIMIT. WHERE применяется к отдельным строкам (до группировки), HAVING — к группам (после). Это ключевое отличие, влияющее на производительность: WHERE может использовать индекс, HAVING — нет.
- HashAggregate (хеш-агрегация): PostgreSQL строит хеш-таблицу группа→агрегат в memory (work_mem). Каждая новая строка вычисляет хеш группового ключа и обновляет агрегат. После обработки всех строк — выдаёт результат. При нехватке work_mem — spill на диск (EXPLAIN показывает 'Batches: N > 1'). Увеличение work_mem может избежать spill.
- GroupAggregate (последовательная агрегация): если входные строки уже отсортированы по GROUP BY ключу — PostgreSQL использует GroupAggregate: читает группу последовательно, вычисляет агрегат, переходит к следующей группе. Не требует хеш-таблицы. Индекс на GROUP BY столбце часто позволяет GroupAggregate без предварительной сортировки.
- COUNT(*) vs COUNT(expr): COUNT(*) подсчитывает все строки результата включая строки с NULL в любом столбце. COUNT(col) подсчитывает строки где col IS NOT NULL. COUNT(DISTINCT col) подсчитывает уникальные не-NULL значения col. Важно: COUNT(col) при наличии NULL строк даёт меньший результат чем COUNT(*).
- AVG игнорирует NULL: AVG(x) = SUM(x) / COUNT(x) — COUNT(x) не включает NULL строки. AVG(5, NULL, 3) = (5+3)/2 = 4, а не (5+0+3)/3 = 2.67. Если нужно включить NULL как 0: AVG(COALESCE(x, 0)). Аналогично SUM игнорирует NULL, но SUM(всех NULL) = NULL (не 0).
- FILTER (WHERE cond) — более эффективный аналог CASE WHEN: COUNT(*) FILTER (WHERE status='ok') vs COUNT(CASE WHEN status='ok' THEN 1 END). Оба работают, но FILTER читабельнее и немного эффективнее (PostgreSQL оптимизирует лучше). FILTER работает со всеми агрегатами: SUM, AVG, STRING_AGG и другими.
- GROUPING SETS — обобщение GROUP BY: GROUP BY GROUPING SETS ((a, b), (a), ()) генерирует три набора групп и объединяет результаты. ROLLUP(a, b, c) = GROUPING SETS ((a,b,c), (a,b), (a), ()). CUBE(a, b) = GROUPING SETS ((a,b), (a), (b), ()). Эффективнее нескольких UNION ALL запросов — данные читаются один раз.
- Функциональная зависимость в GROUP BY: PostgreSQL позволяет включать в SELECT столбцы не из GROUP BY, если они функционально зависят от ключа GROUP BY. GROUP BY u.id, SELECT u.name — разрешено если id PRIMARY KEY (name уникально для каждого id). Это PostgreSQL-расширение; строгий SQL-стандарт требует все неагрегированные столбцы в GROUP BY.
- Materialized views для тяжёлой агрегации: CREATE MATERIALIZED VIEW monthly_sales AS SELECT DATE_TRUNC('month', created_at), SUM(amount) FROM orders GROUP BY 1. Результат сохраняется на диске. REFRESH MATERIALIZED VIEW CONCURRENTLY обновляет без блокировки чтения. Для часто используемых, редко меняющихся агрегатов — значительное ускорение (секунды → миллисекунды).
- Параллельная агрегация: PostgreSQL 10+ поддерживает параллельный GROUP BY. Несколько workers читают разные части таблицы и вычисляют локальные агрегаты, потом Gather merge объединяет. В EXPLAIN: 'Partial HashAggregate' (worker) + 'Finalize HashAggregate' (coordinator). max_parallel_workers_per_gather контролирует количество workers.
- Индексы и GROUP BY: индекс на GROUP BY столбце позволяет GroupAggregate (чтение уже отсортированных данных без Sort node). Покрывающий индекс (включающий все SELECT-столбцы) = Index Only Scan. CREATE INDEX ON orders(user_id) INCLUDE (total_amount) позволяет SELECT user_id, SUM(total_amount) FROM orders GROUP BY user_id делать Index Only Scan без heap access.
Типичные ошибки и заблуждения
- «HAVING заменяет WHERE и работает так же». Фундаментальная ошибка. WHERE фильтрует строки ДО GROUP BY — оптимизатор может использовать индекс, читать меньше данных. HAVING фильтрует ПОСЛЕ — все строки уже прочитаны и сгруппированы. WHERE user_id > 1000 на индексированном столбце = быстрый Index Scan. HAVING user_id > 1000 — полный GroupAggregate по всей таблице. Правило: в WHERE — условия на исходные строки, в HAVING — на агрегаты.
- «GROUP BY автоматически сортирует результат». Нет. GROUP BY не определяет порядок строк в результате. После GROUP BY строки могут прийти в любом порядке. Для детерминированного порядка — обязателен ORDER BY. В разных запусках без ORDER BY порядок может меняться.
- «AVG(col) и SUM(col)/COUNT(*) одно и то же». Это неверно при наличии NULL. AVG(5, NULL, 3) = 4 (SUM/COUNT игнорирует NULL). SUM(5,NULL,3)/COUNT(*) = 8/3 ≈ 2.67 (COUNT(*) считает NULL строку). Правильный аналог: SUM(col)/COUNT(col) (не COUNT(*)) — оба игнорируют NULL.
- «COUNT(DISTINCT col) всегда можно использовать без опасений». COUNT(DISTINCT col) требует подсчёта уникальных значений — в PostgreSQL это либо sort+count либо HyperLogLog (приближённо). На больших таблицах COUNT(DISTINCT user_id) за год может быть медленным. Для приближённого подсчёта: расширение hll (HyperLogLog) или COUNT(DISTINCT ...) с предварительным TABLESAMPLE.
- «NULL в агрегатных функциях считается нулём». Все агрегатные функции (COUNT(col), SUM, AVG, MIN, MAX) ИГНОРИРУЮТ NULL значения. SUM(NULL, NULL) = NULL (не 0). COALESCE(SUM(col), 0) возвращает 0 если все значения NULL. COUNT(*) не игнорирует — он считает строки, не значения. Эта разница критична для корректных отчётов.
- «GROUP BY 1, 2 — это позиции столбцов из SELECT». GROUP BY 1, 2 означает GROUP BY первый и второй столбец из SELECT. Это работает в PostgreSQL, но антипаттерн: при изменении порядка SELECT столбцов GROUP BY меняет смысл. Лучше явно: GROUP BY DATE_TRUNC('month', created_at), category.
- «Один сложный GROUP BY запрос всегда лучше нескольких простых». Не всегда. Сложный запрос с множеством JOIN и GROUPING SETS может быть труднее оптимизировать, чем несколько специализированных запросов с индексами. Оптимизируй на основе EXPLAIN ANALYZE, а не интуиции.
Ключевые выводы
- GROUP BY разбивает строки на группы, агрегатные функции вычисляют одно значение для каждой группы — вся аналитика в БД, не в коде.
- WHERE фильтрует строки ДО группировки (использует индексы), HAVING фильтрует группы ПОСЛЕ — путать их дорого стоит по производительности.
- COUNT(*) считает все строки включая NULL; COUNT(col) считает только не-NULL строки — разница принципиальна.
- FILTER (WHERE ...) — условная агрегация в одном запросе вместо нескольких подзапросов или UNION.
- DATE_TRUNC('month', ts) для группировки по временным периодам; GROUP BY 1 (порядковый номер) — удобно но хрупко.
- GROUPING SETS / ROLLUP / CUBE для иерархических отчётов без UNION ALL.
- STRING_AGG и ARRAY_AGG для объединения значений группы в строку или массив.
Термины урока
Связь с работой backend-разработчика
Золотое правило: вычисляй статистику в SQL, не в коде приложения. Каждый раз когда в коде появляется: загрузить все строки, итерировать, считать суммы/средние/группы — это кандидат для GROUP BY запроса. Практический чеклист: нужно количество X по Y → COUNT(*) GROUP BY Y; нужна сумма X по Y → SUM(X) GROUP BY Y; нужны данные за последний месяц по дням → GROUP BY DATE_TRUNC('day', created_at). Для аналитических запросов на больших таблицах (10M+ строк): добавь индексы на WHERE и GROUP BY столбцы, рассмотри materialized view для тяжёлых запросов которые выполняются часто.
Мини-разбор реальной ситуации
Стартап строит аналитический дашборд. Первая реализация: Rails загружает все заказы за последний квартал (230 000 строк, 45MB), итерирует в Ruby и строит агрегаты для 12 виджетов. Время рендеринга дашборда: 18 секунд. Сервер приложений потребляет 400MB RAM на один запрос. Senior backend разработчик переписывает каждый виджет как отдельный GROUP BY запрос. Самый сложный: 12 строк SQL с DATE_TRUNC, COUNT(DISTINCT), SUM с FILTER для разных статусов. Добавляются два индекса: CREATE INDEX ON orders(created_at) WHERE status != 'cancelled' и CREATE INDEX ON orders(user_id, created_at). Результат: дашборд загружается за 340мс. 8 SQL-запросов вместо 1 с огромной выгрузкой. RAM: 2MB вместо 400MB. Урок: GROUP BY в БД — не оптимизация, это правильная архитектура.
Что запомнить
- WHERE для строк (быстро, индекс), HAVING для групп (медленнее, после агрегации) — не путать!
- COUNT(*) ≠ COUNT(col) при наличии NULL строк — выбирай осознанно.
- FILTER (WHERE ...) вместо нескольких подзапросов — один запрос для нескольких условных агрегатов.
- DATE_TRUNC('month', created_at) для группировки по временным периодам — стандартный паттерн отчётности.
- Вычисляй статистику в SQL: один GROUP BY запрос vs загрузка миллиона строк в память приложения.
Итог
GROUP BY с агрегатными функциями — это язык аналитики встроенный в SQL. Освоив его, ты перестаёшь думать 'загружу данные и посчитаю в коде' и начинаешь мыслить запросами: что мне нужно посчитать, по каким группам, с какими условиями. Каждый дашборд, каждый KPI, каждый отчёт — это один правильно написанный GROUP BY запрос.