Группировка в SQL: практический пример с заказами
На примере заказов разберём, как сгруппировать данные по клиентам и месяцам, посчитать выручку и отсеять маленькие суммы. Покажем, чем WHERE отличается от HAVING и где группировка меняет смысл отчёта.

Допустим, вам нужен отчёт по оплаченной выручке: сколько каждый клиент принёс за каждый месяц. В PostgreSQL 16 это решается через GROUP BY и агрегатные функции. На примере ниже разберём запрос, который считает заказы и суммы, отбрасывает группы меньше заданного порога и показывает, как выбрать подходящий уровень группировки.
Данные и запрос для отчёта
Запрос работает с таблицей заказов: в каждой строке один заказ, а поля содержат клиента, дату, статус и сумму. Для самостоятельного запуска достаточно PostgreSQL 16; CTE в примере создаёт временный набор строк, поэтому существующая таблица не нужна.
WITH orders (order_id, customer, ordered_at, status, amount) AS (
VALUES
(101, 'Анна', DATE '2025-01-04', 'paid', 1200::numeric),
(102, 'Анна', DATE '2025-01-20', 'paid', 800::numeric),
(103, 'Борис', DATE '2025-01-11', 'paid', 2000::numeric),
(104, 'Анна', DATE '2025-02-02', 'paid', 500::numeric),
(105, 'Борис', DATE '2025-02-03', 'cancelled', 900::numeric),
(106, 'Борис', DATE '2025-02-10', 'paid', 700::numeric)
)
SELECT
customer,
date_trunc('month', ordered_at)::date AS month,
count(*) AS orders_count,
sum(amount) AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY customer, date_trunc('month', ordered_at)
HAVING sum(amount) >= 1000
ORDER BY month, revenue DESC;
Для Анны за январь запрос вернёт два заказа и выручку 2000. Борису за февраль останется 700, но эта строка не попадёт в результат: она не проходит HAVING. Отменлённый заказ исключается раньше — условием WHERE.
Агрегатная функция сводит несколько строк в одно значение: например, count(*) считает строки группы, а sum(amount) складывает суммы. Группа — набор строк с одинаковыми значениями полей, указанных в GROUP BY.
Что именно делает GROUP BY
В этом отчёте строки объединяются по двум признакам: клиенту и месяцу. Если убрать месяц из GROUP BY, январские и февральские заказы клиента попадут в одну общую группу. Если, наоборот, добавить туда дату заказа, группировка станет почти построчной: отдельная дата будет отдельным ключом.
В практическом запросе каждое поле в SELECT должно либо входить в GROUP BY, либо быть аргументом агрегатной функции. Иначе непонятно, какое значение поля показывать для группы из нескольких строк. PostgreSQL может разрешать отдельные случаи, связанные с первичным ключом, но для отчётов безопаснее явно задавать нужные поля группировки.
GROUP BY отвечает не на вопрос «что посчитать», а на вопрос «для каких наборов строк посчитать». Ошибка в уровне группировки даёт правдоподобные числа — и поэтому особенно опасна.
Три варианта группировки для разных отчётов
Выбирайте группировку по тому разрезу, в котором будете принимать решение. Одна и та же сумма может быть верной для отчёта по клиенту и бесполезной для анализа динамики по месяцам.
| Задача | Группировка | Что получите | Цена выбора |
|---|---|---|---|
| Общая выручка каждого клиента | customer | Одна строка на клиента за весь выбранный период | Теряется динамика по месяцам |
| Сравнить клиентов по месяцам | customer, месяц | Одна строка на сочетание клиента и месяца | Результат содержит больше строк; месяцы нужно выделить из даты |
| Получить детали и итоги одним запросом | GROUPING SETS для нескольких наборов полей | Строки по клиентам и месяцам вместе с промежуточными итогами | Нужно отличать итоговые строки от обычных, иначе NULL может выглядеть как пропуск данных |
Для первого варианта удалите выражение с месяцем из SELECT и GROUP BY. Для сводки по клиентам и месяцам оставьте пример выше. GROUPING SETS полезен, когда несколько уровней итога действительно нужны в одном результате; если отчёт большой или его читает дашборд, нередко проще выполнить отдельные понятные запросы. Документация PostgreSQL описывает GROUPING SETS и способы различать уровни группировки.
WHERE и HAVING решают разные задачи
Условия выполняются в разной части обработки запроса:
- WHERE отбирает исходные строки до группировки. В примере он убирает отменённые заказы.
- HAVING отбирает уже собранные группы. Здесь он оставляет клиентов и месяцы с выручкой не меньше 1000.
Не переносите условие между ними механически. Если написать WHERE amount >= 1000, из расчёта исчезнет заказ Анны на 800, и январская выручка станет 1200 вместо 2000. Это уже другой вопрос: сумма заказов, каждый из которых сам по себе не меньше порога.
Что ломается в реальных данных
- Сумма выросла после JOIN. Если присоединить к заказам таблицу товаров, где одному заказу соответствуют несколько строк, сумма заказа может повториться. Сначала проверьте кардинальность связи и гранулярность таблиц; при необходимости агрегируйте товары до одной строки на заказ до присоединения.
- COUNT(*) считают как число клиентов. Он считает строки внутри каждой группы. Чтобы посчитать уникальных клиентов, обычно группируют по клиенту и считают группы, а не суммируют COUNT(*) по деталям без проверки.
- NULL пропадает из SUM. Агрегатные функции вроде SUM игнорируют NULL, а если в группе все значения NULL, результат суммы тоже будет NULL. Проверьте смысл пропуска: неизвестная сумма — не обязательно ноль.
- Месяц разбит на несколько групп. Группировка по исходной дате создаёт отдельные группы для разных дней. Для месячного отчёта сначала приводите дату к месяцу, как в date_trunc в примере.
- Сумма не совпадает с учётом. Уточните, включаются ли возвраты, скидки, налоги и неоплаченные заказы. SQL складывает выбранные значения, но бизнес-правило нужно определить до запроса.
Как выбрать и проверить запрос
Для отчёта по выручке на старте хватит одного уровня группировки и явного фильтра оплаченных строк. Если нужны итоги по нескольким разрезам, добавляйте их только после проверки базового результата.
- Запишите, что означает одна строка результата: например, один клиент за один месяц.
- Проверьте исходную таблицу на дубли и уточните, что означает одна строка до JOIN.
- Сверьте сумму по одной небольшой группе вручную, сложив исходные строки.
- Сравните общий итог запроса с независимым подсчётом по тем же статусам и датам.
Если ручная проверка не сходится, не маскируйте проблему округлением. Сначала проверьте фильтры, дубли после соединений и состав исходных строк.
Что сделать дальше
Запустите пример, затем добавьте в набор несколько заказов с одинаковой датой и статусом и проверьте, что меняется при группировке только по клиенту. После этого повторите проверку на рабочей таблице: сравните сумму до и после каждого JOIN и отдельно проверьте строки с NULL. Так вы поймёте не только синтаксис GROUP BY, но и насколько отчёт соответствует реальному смыслу данных.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: PostgreSQL 16: таблицы и выражение GROUP BY, PostgreSQL 16: агрегатные функции
Частые вопросы
Можно ли использовать алиас month в GROUP BY?
Чем COUNT(*) отличается от COUNT(amount)?
Почему сумма после JOIN стала больше?
Можно ли поставить условие по сумме в WHERE?
Что делать, если у меня не PostgreSQL 16, а другая версия или СУБД?
Почему GROUP BY вернул NULL или строк меньше, чем я ожидал?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.