SQL: Группировки и агрегатные функции¶
Обычные запросы SELECT возвращают сырые данные — строку за строкой. Но в аналитике чаще нужно обратное: «сжать» тысячи записей в одну цифру. Сколько заказов сделал клиент? Какова средняя цена товара в категории? Какова общая выручка за месяц?
В этой статье ты пройдёшь путь от базовых агрегатов до продвинутых отчётов с ROLLUP, FILTER и STRING_AGG. Все примеры на схеме etl_kitchen_db.
Агрегатные функции: инструментарий аналитика¶
Агрегатные функции вычисляют единый результат для набора строк.
| Функция | Что делает | Пример на etl_kitchen_db |
|---|---|---|
COUNT(*) |
Считает все строки | Количество заказов в orders |
COUNT(col) |
Считает не-NULL значения | COUNT(delivery_date) — сколько доставлено |
SUM |
Сумма | SUM(revenue) в fact_sales |
AVG |
Среднее арифметическое | AVG(total_amount) — средний чек |
MIN / MAX |
Экстремумы | Самый дорогой товар в категории |
-- Базовые метрики без группировки (вся таблица = одна группа)
SELECT
COUNT(*) AS total_products,
ROUND(AVG(price), 2) AS avg_price,
MIN(price) AS cheapest,
MAX(price) AS most_expensive
FROM products;
| total_products | avg_price | cheapest | most_expensive |
|---|---|---|---|
| 25 | 33596.00 | 490.00 | 89990.00 |
COUNT(*) vs COUNT(col)
COUNT(*) считает все строки, включая NULL. COUNT(delivery_date) считает только строки, где delivery_date заполнена. Остальные агрегаты (SUM, AVG, MIN, MAX) игнорируют NULL.
GROUP BY: от строк к группам¶
GROUP BY разделяет результат на группы строк с одинаковыми значениями. Главное правило: все колонки в SELECT, не обёрнутые в агрегатную функцию, обязательно должны быть в GROUP BY.
Базовый пример¶
-- Количество товаров и средняя цена в каждой категории
SELECT
cat.name AS category,
COUNT(*) AS total_products,
ROUND(AVG(p.price), 2) AS avg_price
FROM products p
JOIN categories cat ON p.category_id = cat.category_id
GROUP BY cat.name
ORDER BY total_products DESC;
| category | total_products | avg_price |
|---|---|---|
| Продукты питания | 8 | 5271.25 |
| Электроника | 6 | 53328.33 |
| Книги | 5 | 2196.00 |
| Бытовая техника | 4 | 44747.50 |
| Одежда | 2 | 12490.00 |
Выручка по категориям: цепочка из 4 таблиц¶
Реальные отчёты требуют нескольких JOIN. Путь от продажи до категории: orders → order_items → products → categories.
SELECT
cat.name AS category,
SUM(oi.quantity * oi.unit_price) AS total_revenue,
COUNT(DISTINCT o.order_id) AS orders_count
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
JOIN categories cat ON p.category_id = cat.category_id
WHERE o.status = 'delivered'
GROUP BY cat.name
ORDER BY total_revenue DESC;
Группировка по номеру колонки
В PostgreSQL можно писать GROUP BY 1 вместо GROUP BY cat.name. Это сокращает код, но будь осторожен: если изменишь порядок колонок в SELECT, группировка сломается.
Функциональная зависимость (Pro)¶
В PostgreSQL, если ты группируешь по первичному ключу таблицы, остальные колонки этой таблицы можно использовать в SELECT без агрегатов — база понимает, что они однозначно зависят от PK.
-- PostgreSQL позволяет: GROUP BY product_id, а p.name и p.price — без агрегата
SELECT p.product_id, p.name, p.price, SUM(oi.quantity) AS total_sold
FROM products p
JOIN order_items oi USING (product_id)
GROUP BY p.product_id;
Только PostgreSQL
Другие СУБД (Oracle, MySQL в strict mode) потребуют перечислить все колонки в GROUP BY. Для переносимости лучше указывать явно.
HAVING: фильтрация групп¶
WHERE фильтрует строки до группировки, HAVING — после. В WHERE нельзя использовать агрегаты.
-- Города, где средний чек выше 50 000
SELECT
c.city,
ROUND(AVG(o.total_amount), 2) AS avg_check,
COUNT(o.order_id) AS orders_count
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.city
HAVING COUNT(o.order_id) >= 3
ORDER BY avg_check DESC;
Порядок выполнения SQL¶
Запомни «трубопровод» — порядок, в котором СУБД обрабатывает запрос:
| Этап | Что делает | Агрегаты? |
|---|---|---|
WHERE |
Отсекает строки до группировки | Запрещены |
GROUP BY |
Формирует группы | — |
HAVING |
Отсекает группы по результатам агрегатов | Разрешены |
-- Ошибка! SUM() нельзя в WHERE
SELECT customer_id, SUM(total_amount)
FROM orders
WHERE SUM(total_amount) > 100000 -- ← ОШИБКА
GROUP BY customer_id;
-- Правильно: фильтр — в HAVING
SELECT customer_id, SUM(total_amount) AS total_spent
FROM orders
GROUP BY customer_id
HAVING SUM(total_amount) > 100000;
Оптимизация
Фильтруй в WHERE всё, что можно: статусы, даты, типы. Это сокращает объём данных до группировки и ускоряет запрос. HAVING — только для условий на агрегаты.
CASE внутри агрегата: Pivot-паттерн¶
CASE внутри агрегата позволяет посчитать несколько метрик из одной колонки в одном запросе:
SELECT
COUNT(*) AS total_orders,
SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) AS delivered,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled,
ROUND(
100.0 * SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) / COUNT(*),
1
) AS delivery_rate_pct
FROM orders;
| total_orders | delivered | cancelled | delivery_rate_pct |
|---|---|---|---|
| 50 | 20 | 5 | 40.0 |
FILTER: элегантная альтернатива CASE (PostgreSQL)¶
PostgreSQL предлагает FILTER (WHERE ...) — чистый способ условной агрегации без CASE:
SELECT
customer_id,
COUNT(*) AS total_orders,
COUNT(*) FILTER (WHERE status = 'delivered') AS delivered,
SUM(total_amount) FILTER (WHERE status = 'delivered') AS delivered_revenue,
SUM(total_amount) FILTER (WHERE status = 'cancelled') AS cancelled_loss
FROM orders
GROUP BY customer_id
ORDER BY delivered_revenue DESC NULLS LAST
LIMIT 5;
FILTER vs CASE: сравнение¶
| Подход | Пример | Плюсы |
|---|---|---|
CASE |
COUNT(CASE WHEN status = 'delivered' THEN 1 END) |
Работает в любой СУБД |
FILTER |
COUNT(*) FILTER (WHERE status = 'delivered') |
Читабельнее, понятнее планировщику |
FILTER — расширение PostgreSQL
Если нужна переносимость на MySQL/Oracle — используй CASE. В PostgreSQL FILTER работает с любым агрегатом: COUNT, SUM, AVG, MIN, MAX и даже кастомными.
STRING_AGG и ARRAY_AGG: сборка значений¶
Эти функции «склеивают» значения группы в одно поле — текстовую строку или массив.
STRING_AGG: для отчётов¶
-- Список товаров в каждом заказе, через запятую (по алфавиту)
SELECT
oi.order_id,
STRING_AGG(p.name, ', ' ORDER BY p.name) AS products_list
FROM order_items oi
JOIN products p USING (product_id)
GROUP BY oi.order_id
LIMIT 3;
| order_id | products_list |
|---|---|
| 1 | Смартфон Galaxy S24 |
| 2 | Кофе молотый 250г, Чай зелёный 100п |
| 3 | Ноутбук MacBook Pro, Стиральная машина LG, Утюг Philips |
ARRAY_AGG: для приложений¶
-- Массив ID товаров — удобно передать в API
SELECT
order_id,
ARRAY_AGG(product_id ORDER BY product_id) AS product_ids
FROM order_items
GROUP BY order_id
LIMIT 3;
| order_id | product_ids |
|---|---|
| 1 | {1} |
| 2 | {15, 16} |
| 3 | {2, 9, 10} |
| Функция | Тип результата | Когда использовать |
|---|---|---|
STRING_AGG |
Текст | Отчёты для людей |
ARRAY_AGG |
Массив | API, программная обработка |
Приведение типов
STRING_AGG требует текстовый вход. Для чисел: STRING_AGG(product_id::text, ', ').
Группировка по выражениям: DATE_TRUNC и EXTRACT¶
В аналитике часто нужно менять «масштаб» времени: смотреть продажи не по дням, а по месяцам или кварталам.
DATE_TRUNC: временные ряды¶
-- Выручка по месяцам
SELECT
DATE_TRUNC('month', order_date) AS month_start,
SUM(total_amount) AS monthly_revenue,
COUNT(*) AS orders_count
FROM orders
WHERE status != 'cancelled'
GROUP BY month_start
ORDER BY month_start;
DATE_TRUNC('month', '2024-06-25') → 2024-06-01. Все заказы одного месяца попадают в одну группу.
EXTRACT: анализ сезонности¶
-- Количество заказов по кварталам (все годы вместе)
SELECT
EXTRACT(QUARTER FROM order_date) AS quarter,
COUNT(*) AS total_orders,
ROUND(AVG(total_amount), 2) AS avg_check
FROM orders
GROUP BY quarter
ORDER BY quarter;
| Функция | Что возвращает | Когда использовать |
|---|---|---|
DATE_TRUNC |
Дату (начало периода) | Таймлайны, тренды, графики |
EXTRACT |
Число (номер месяца/квартала) | Сезонность, циклы |
TO_CHAR |
Текст ('March 2024') |
Красивые заголовки в отчётах |
dim_date вместо функций
В аналитическом слое etl_kitchen_db есть готовая таблица dim_date. Для fact_sales лучше JOIN с dim_date и группировка по d.year, d.quarter — это быстрее и нагляднее:
ROLLUP, CUBE и GROUPING SETS¶
Для сложных отчётов с итогами и подитогами PostgreSQL предлагает расширения GROUP BY.
ROLLUP: иерархические итоги¶
-- Выручка: по категории → общий итог
SELECT
COALESCE(cat.name, '=== ИТОГО ===') AS category,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
JOIN categories cat ON p.category_id = cat.category_id
GROUP BY ROLLUP (cat.name)
ORDER BY revenue DESC;
| category | revenue |
|---|---|
| === ИТОГО === | 1234567.00 |
| Электроника | 890000.00 |
| Бытовая техника | 200000.00 |
| ... | ... |
ROLLUP добавляет строку с NULL в группирующей колонке — это итог. COALESCE превращает NULL в читаемый заголовок.
CUBE: все комбинации¶
-- Итоги по городу, по статусу и все пересечения
SELECT
c.city,
o.status,
COUNT(*) AS orders_count,
SUM(o.total_amount) AS revenue
FROM orders o
JOIN customers c USING (customer_id)
GROUP BY CUBE (c.city, o.status)
ORDER BY c.city NULLS LAST, o.status NULLS LAST;
CUBE(city, status) генерирует: по городу × статусу, по городу, по статусу и общий итог.
GROUPING SETS: ручной выбор разрезов¶
-- Только два разреза: по городу и по статусу (без пересечений)
SELECT c.city, o.status, COUNT(*) AS cnt
FROM orders o
JOIN customers c USING (customer_id)
GROUP BY GROUPING SETS ((c.city), (o.status));
GROUPING SETS эффективнее, чем UNION ALL нескольких запросов — СУБД делает один проход по данным.
NULL-безопасность: COALESCE и NULLIF¶
COALESCE: значение по умолчанию¶
-- Клиенты без заказов получат 0, а не NULL
SELECT c.name, COALESCE(SUM(o.total_amount), 0) AS total_spent
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.name
ORDER BY total_spent;
NULLIF: защита от деления на ноль¶
-- Средняя стоимость позиции, безопасное деление
SELECT
order_id,
SUM(quantity * unit_price) / NULLIF(SUM(quantity), 0) AS avg_item_price
FROM order_items
GROUP BY order_id;
NULLIF(x, 0) возвращает NULL при x = 0 → деление вернёт NULL вместо ошибки.
Типичные ошибки¶
1. Колонка не в GROUP BY¶
-- ОШИБКА: p.name не указан в GROUP BY
SELECT p.name, SUM(oi.quantity * oi.unit_price)
FROM products p
JOIN order_items oi USING (product_id)
GROUP BY p.category_id;
-- Правильно: добавь p.name в GROUP BY (или группируй по p.product_id — PK)
2. Агрегат в WHERE¶
-- ОШИБКА: SUM() нельзя в WHERE
WHERE SUM(total_amount) > 100000
-- Правильно: используй HAVING
HAVING SUM(total_amount) > 100000
3. Агрегат от агрегата¶
-- ОШИБКА: AVG(SUM(revenue)) — вложенный агрегат запрещён
SELECT AVG(SUM(revenue)) FROM fact_sales GROUP BY category_id;
-- Правильно: подзапрос
SELECT ROUND(AVG(cat_total), 2) AS avg_category_revenue
FROM (
SELECT category_id, SUM(revenue) AS cat_total
FROM fact_sales
GROUP BY category_id
) sub;
4. Простой фильтр в HAVING вместо WHERE¶
-- Медленно: фильтрация по дате после группировки
HAVING EXTRACT(YEAR FROM order_date) = 2024
-- Быстро: отсечь строки ДО группировки
WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'
Шпаргалка¶
| Задача | Инструмент |
|---|---|
| Метрики по всей таблице | Агрегат без GROUP BY |
| Метрики в разрезе | GROUP BY |
| Отбросить группы по условию | HAVING |
| Pivot: несколько метрик из одной колонки | CASE внутри агрегата |
| Условная агрегация (PostgreSQL) | FILTER (WHERE ...) |
| Список значений в строку | STRING_AGG |
| Список значений в массив | ARRAY_AGG |
| Группировка по месяцам/кварталам | DATE_TRUNC / EXTRACT |
| Итоги и подитоги | ROLLUP / CUBE / GROUPING SETS |
| Защита от NULL | COALESCE, NULLIF |
Что запомнить¶
- Порядок выполнения:
WHERE→GROUP BY→HAVING→SELECT→ORDER BY - Все колонки в
SELECTбез агрегата должны быть вGROUP BY COUNT(*)считает все строки,COUNT(col)— только не-NULLHAVING— для фильтров на агрегаты,WHERE— для фильтров на строкиFILTER (WHERE ...)— чистая альтернативаCASEв PostgreSQLSTRING_AGG/ARRAY_AGG— собирают значения группы в одно полеDATE_TRUNCдля таймлайнов,EXTRACTдля сезонностиROLLUP/CUBE/GROUPING SETS— итоги и подитоги в одном запросе- NULL попадает в отдельную группу при
GROUP BY - Фильтруй в
WHEREмаксимально рано — это ускоряет запрос
Попробуй сам¶
Проверь себя¶
Что дальше?¶
Ты умеешь группировать данные и строить отчёты. Следующий шаг — Подзапросы и CTE, где ты научишься строить многоуровневые запросы.
Источники¶
- PostgreSQL: Aggregate Functions — полный список агрегатов, включая
STRING_AGG,ARRAY_AGG,FILTER - PostgreSQL: GROUP BY and HAVING — группировка,
ROLLUP,CUBE,GROUPING SETS - PostgreSQL: Conditional Expressions —
CASE,COALESCE,NULLIF - PostgreSQL: Date/Time Functions —
DATE_TRUNC,EXTRACT,TO_CHAR