Перейти к содержанию

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 Экстремумы Самый дорогой товар в категории
SQL
-- Базовые метрики без группировки (вся таблица = одна группа)
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.

Базовый пример

SQL
-- Количество товаров и средняя цена в каждой категории
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. Путь от продажи до категории: ordersorder_itemsproductscategories.

SQL
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.

SQL
-- 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 нельзя использовать агрегаты.

SQL
-- Города, где средний чек выше 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

Запомни «трубопровод» — порядок, в котором СУБД обрабатывает запрос:

Text Only
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
Этап Что делает Агрегаты?
WHERE Отсекает строки до группировки Запрещены
GROUP BY Формирует группы
HAVING Отсекает группы по результатам агрегатов Разрешены
SQL
-- Ошибка! 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 внутри агрегата позволяет посчитать несколько метрик из одной колонки в одном запросе:

SQL
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:

SQL
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: для отчётов

SQL
-- Список товаров в каждом заказе, через запятую (по алфавиту)
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: для приложений

SQL
-- Массив 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: временные ряды

SQL
-- Выручка по месяцам
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: анализ сезонности

SQL
-- Количество заказов по кварталам (все годы вместе)
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 — это быстрее и нагляднее:

SQL
SELECT d.year, d.quarter, SUM(f.revenue)
FROM fact_sales f
JOIN dim_date d ON f.date_id = d.date_id
GROUP BY d.year, d.quarter;


ROLLUP, CUBE и GROUPING SETS

Для сложных отчётов с итогами и подитогами PostgreSQL предлагает расширения GROUP BY.

ROLLUP: иерархические итоги

SQL
-- Выручка: по категории → общий итог
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: все комбинации

SQL
-- Итоги по городу, по статусу и все пересечения
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: ручной выбор разрезов

SQL
-- Только два разреза: по городу и по статусу (без пересечений)
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: значение по умолчанию

SQL
-- Клиенты без заказов получат 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: защита от деления на ноль

SQL
-- Средняя стоимость позиции, безопасное деление
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

SQL
-- ОШИБКА: 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

SQL
-- ОШИБКА: SUM() нельзя в WHERE
WHERE SUM(total_amount) > 100000

-- Правильно: используй HAVING
HAVING SUM(total_amount) > 100000

3. Агрегат от агрегата

SQL
-- ОШИБКА: 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

SQL
-- Медленно: фильтрация по дате после группировки
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

Что запомнить

  • Порядок выполнения: WHEREGROUP BYHAVINGSELECTORDER BY
  • Все колонки в SELECT без агрегата должны быть в GROUP BY
  • COUNT(*) считает все строки, COUNT(col) — только не-NULL
  • HAVING — для фильтров на агрегаты, WHERE — для фильтров на строки
  • FILTER (WHERE ...) — чистая альтернатива CASE в PostgreSQL
  • STRING_AGG / ARRAY_AGG — собирают значения группы в одно поле
  • DATE_TRUNC для таймлайнов, EXTRACT для сезонности
  • ROLLUP / CUBE / GROUPING SETS — итоги и подитоги в одном запросе
  • NULL попадает в отдельную группу при GROUP BY
  • Фильтруй в WHERE максимально рано — это ускоряет запрос

Попробуй сам


Проверь себя


Что дальше?

Ты умеешь группировать данные и строить отчёты. Следующий шаг — Подзапросы и CTE, где ты научишься строить многоуровневые запросы.


Источники