SQL-агрегации без страха: GROUP BY, HAVING, оконные функции и хитрости
Построим понятный ментальный модель для агрегатов и условных группировок. Разберём оконные функции и типовые ошибки в запросах отчётов.
Содержание
SQL-агрегации без страха: GROUP BY, HAVING, оконные функции и хитрости
Агрегации в SQL — это тот участок языка, где многие либо «механически копируют запросы», либо начинают бояться. Причина обычно не в сложности синтаксиса, а в отсутствии устойчивой ментальной модели: что именно агрегируется, когда применяется фильтрация, и почему иногда запросы ведут себя «как будто логика сломалась».
В этой статье разберём агрегации и условные группировки так, чтобы вы могли уверенно строить отчёты любой сложности: от простых сводных таблиц до аналитических запросов с оконными функциями. Отдельно разберём типовые ошибки, которые чаще всего встречаются в реальных проектах.
Ментальная модель: что такое агрегат и где он «живёт»
Агрегация как преобразование уровня строк в уровень групп
Представьте таблицу как поток строк. Оператор агрегирования (в SQL это COUNT, SUM, AVG, MIN, MAX и т.д.) переводит данные с «уровня строк» на «уровень групп».
- До
GROUP BYвы работаете с конкретными строками. - После
GROUP BYвы получаете по одной строке результата на каждую группу.
Пример. Есть продажи:
sales(user_id, product_id, sale_date, amount)
Если вы хотите посчитать суммарные продажи по пользователям, вы просите SQL:
- сгруппировать строки по
user_id; - агрегировать
amountвнутри каждой группы.
Тогда результат будет иметь одну строку на каждого пользователя.
SELECT
user_id,
SUM(amount) AS total_amount
FROM sales
GROUP BY user_id;
Почему это важно: ограничения на SELECT при наличии GROUP BY
Когда используется GROUP BY, в SELECT можно выводить:
- только поля, которые участвуют в группировке;
- или агрегаты (вычисления внутри группы).
Попытка вывести «не сгруппированное» поле почти всегда приводит к ошибке или к неочевидному поведению (в зависимости от режима SQL). Например:
SELECT
user_id,
sale_date, -- <- нельзя без агрегирования
SUM(amount) AS total_amount
FROM sales
GROUP BY user_id;
Ментальная модель здесь такая: sale_date внутри группы множества значений. Нужно либо выбрать правило (агрегат/функция), либо изменить уровень детализации.
GROUP BY: базовые сценарии и подводные камни
Группировка по нескольким полям
Часто отчёт строится не по одному ключу. Например, сумма продаж по пользователю и продукту:
SELECT
user_id,
product_id,
SUM(amount) AS total_amount
FROM sales
GROUP BY user_id, product_id;
Группы образуются по паре (user_id, product_id). Это фундаментальная идея: GROUP BY — это определение ключа группы.
NULL в группировках: отдельная категория или игнорирование
В большинстве СУБД NULL группируется отдельно: все NULL в одном столбце образуют группу. Это значит, что запрос:
SELECT product_id, COUNT(*)
FROM sales
GROUP BY product_id;
может вернуть строку с product_id = NULL.
Иногда это поведение нежелательно: вы хотите считать такие строки как «неизвестно» и, например, не включать их в отчёт. Тогда используйте WHERE, а не HAVING (об этом дальше).
Ошибка №1: путаница между WHERE и HAVING
Это один из главных источников проблем. Разница:
WHEREфильтрует строки до группировки.HAVINGфильтрует группы после группировки.
Пример. Нужно посчитать пользователей, у которых суммарная сумма продаж больше 1000.
Правильно:
SELECT
user_id,
SUM(amount) AS total_amount
FROM sales
GROUP BY user_id
HAVING SUM(amount) > 1000;
Неправильно (или невозможно корректно выразить с тем же смыслом) так:
SELECT
user_id,
SUM(amount) AS total_amount
FROM sales
WHERE SUM(amount) > 1000 -- <- не имеет смысла: SUM ещё не посчитан
GROUP BY user_id;
Мелкая, но важная хитрость: HAVING + агрегаты vs HAVING + алиасы
В разных СУБД правила с алиасами в HAVING различаются. Часто встречается, что HAVING total_amount > 1000 работает, если алиас определён в SELECT. Но полагаться на это без понимания конкретной СУБД — риск. Надёжный вариант — повторить выражение:
HAVING SUM(amount) > 1000
HAVING: условные группировки без страха
Фильтрация групп по количеству
Например, найти продукты, у которых было хотя бы 10 продаж:
SELECT
product_id,
COUNT(*) AS cnt
FROM sales
GROUP BY product_id
HAVING COUNT(*) >= 10;
Это типичный запрос для отчётов качества данных (например, «достаточно данных для анализа») или бизнес-условий.
Фильтрация групп по вычисленным метрикам
Например, средний чек по пользователю должен быть не меньше 50:
SELECT
user_id,
AVG(amount) AS avg_check
FROM sales
GROUP BY user_id
HAVING AVG(amount) >= 50;
Подводный камень: HAVING не заменяет WHERE
Если условие относится к отдельным строкам (например, продажи до конкретной даты), то логика должна быть в WHERE.
Допустим, нужно посчитать пользователей по продажам за июль:
SELECT
user_id,
SUM(amount) AS total_amount
FROM sales
WHERE sale_date >= DATE '2026-07-01'
AND sale_date < DATE '2026-08-01'
GROUP BY user_id
HAVING SUM(amount) > 1000;
Если перенести фильтр даты в HAVING (что иногда пытаются сделать через трюки с выражениями), вы рискуете:
- усложнить запрос;
- получить неправильные результаты;
- ухудшить производительность, потому что группировка может происходить на большем объёме данных.
Условная группировка: CASE, “подсчёт только нужного”, и почему это работает
Часто требуется агрегация с условием внутри группы: «сколько успешных» или «сколько продаж с определённым флагом».
Вариант 1: COUNT + CASE
Например, посчитать количество успешных заказов и общее количество:
SELECT
user_id,
COUNT(*) AS total_orders,
COUNT(CASE WHEN status = 'success' THEN 1 END) AS successful_orders
FROM orders
GROUP BY user_id;
COUNT(expr) в SQL учитывает только строки, где expr не NULL. Поэтому CASE должен возвращать NULL для «неподходящих» строк и 1 для «подходящих».
Альтернатива — SUM по булевому выражению, но это зависит от диалекта SQL. В стандарте безопаснее использовать CASE.
Вариант 2: SUM + CASE для суммирования по условию
Сумма только успешных продаж:
SELECT
user_id,
SUM(CASE WHEN status = 'success' THEN amount ELSE 0 END) AS successful_amount,
SUM(amount) AS total_amount
FROM orders
GROUP BY user_id;
Ошибка №2: забывают ELSE и получают NULL в SUM
Если использовать:
SUM(CASE WHEN status = 'success' THEN amount END)
то для неподходящих строк будет NULL. SUM обычно игнорирует NULL, что иногда ок, но в отчётах это может ломать ожидания (например, когда нужна явная нулевая сумма). Поэтому выбор ELSE 0 делает смысл однозначным.
Когда агрегаты нужны вместе с деталями: “один запрос — два уровня”
Классическая проблема: хочется вывести детали и при этом агрегаты.
Например: список пользователей и в каждой строке — их суммарные продажи. На уровне пользователя это нормально, но вы хотите видеть, возможно, ещё и дополнительные поля: “последняя покупка”, “номер месяца”, “самая крупная покупка”.
Самый простой путь — перестроить запрос в два этапа: агрегат по одному уровню, затем соединение. Но в аналитике часто удобнее использовать оконные функции.
Оконные функции: когда GROUP BY уже недостаточно
Оконная функция отличается от агрегата тем, что сохраняет строки
Условно:
GROUP BYуменьшает количество строк (сворачивает в группы).- Оконная функция считает значение, но не сворачивает строки — каждая строка остаётся на месте.
Синтаксис:
<агрегат_или_аналитическая_функция> OVER (
PARTITION BY ...
ORDER BY ...
<frame>
)
PARTITION BYзадаёт, по каким ключам “разделить” данные на окна (аналог групп).ORDER BYзадаёт порядок в окне (для ранжирования, накопительных функций, “текущего момента”).frame(опционально) управляет диапазоном для накопительных окон.
Пример: сумма продаж по пользователю без GROUP BY
Вернёмся к продажам. Хотим показать каждой строке продажи сумму по пользователю:
SELECT
s.*,
SUM(s.amount) OVER (PARTITION BY s.user_id) AS total_amount_for_user
FROM sales s;
Результат будет иметь все исходные строки, но рядом — вычисленная метрика “общая сумма пользователя”.
Типовые оконные сценарии для отчётов
1) Ранжирование: ROW_NUMBER, RANK, DENSE_RANK
Часто нужно найти “топ-1” или “топ-N” внутри категории.
Например: лучший продукт по сумме продаж в рамках каждого пользователя.
Схема: считаем сумму по продукту, ранжируем внутри пользователя, берём первые.
WITH product_sums AS (
SELECT
user_id,
product_id,
SUM(amount) AS product_amount
FROM sales
GROUP BY user_id, product_id
),
ranked AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY product_amount DESC
) AS rn
FROM product_sums
)
SELECT
user_id,
product_id,
product_amount
FROM ranked
WHERE rn = 1;
Здесь ROW_NUMBER гарантирует уникальные позиции даже при равных суммах. Если нужна “позиция как в таблице рангов” — можно использовать RANK или DENSE_RANK.
Ошибка №3: путаница ROW_NUMBER и RANK
Если у вас есть равные значения и вы ожидаете одинаковые ранги для равных сумм, ROW_NUMBER не даст этого. Он всегда различает строки. Для равных значений используйте RANK().
2) Доля от общей суммы
Отчёт “сколько процентов от суммы” часто удобнее делать оконной функцией:
SELECT
product_id,
amount,
SUM(amount) OVER () AS total_all,
amount * 1.0 / SUM(amount) OVER () AS share_of_total
FROM sales;
Но это пример на уровне строк. Обычно в отчёте агрегат по продукту:
WITH product_sums AS (
SELECT
product_id,
SUM(amount) AS product_amount
FROM sales
GROUP BY product_id
)
SELECT
product_id,
product_amount,
product_amount * 1.0 / SUM(product_amount) OVER () AS share_of_total
FROM product_sums;
3) Накопительные суммы (running totals)
Например, накопленный доход по дням:
SELECT
sale_date,
amount,
SUM(amount) OVER (
ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM sales
ORDER BY sale_date;
Даже если у вас несколько строк на одну дату — результат зависит от порядка. Для корректного анализа обычно группируют по дате или добавляют дополнительные поля в ORDER BY, чтобы воспроизводимость была гарантирована.
Ошибка №4: отсутствует ORDER BY в окне для накопительных метрик
Накопительная функция без ORDER BY превращается в “сумму по partition” (то есть без временной динамики), что почти всегда неверно для временных рядов.
Frame в оконных функциях: тонкости, которые реально всплывают
Оконные функции в SQL могут иметь рамки (frame), которые определяют, какие строки входят в окно для вычисления.
По умолчанию frame зависит от функции и диалекта. Чтобы избежать сюрпризов, лучше явно задавать ROWS или RANGE.
Например, скользящее окно за последние 7 строк:
SELECT
user_id,
sale_date,
amount,
SUM(amount) OVER (
PARTITION BY user_id
ORDER BY sale_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS sum_last_7_rows
FROM sales;
Если вы используете RANGE, смысл будет зависеть от значений в ORDER BY. Для дат это может быть очень похоже на “последние N дней”, но важно понимать разницу между логикой “по расстоянию в значениях” и “по количеству строк”.
Сравнение подходов: GROUP BY + JOIN vs оконные функции
Когда лучше GROUP BY
- Когда вы хотите свернуть данные (агрегатный отчёт).
- Когда проще логически выразить именно уровень “группы”.
- Когда нужно меньше вычислений на больших объёмах данных (хотя оптимизация зависит от СУБД).
Когда лучше оконные функции
- Когда вам нужно сохранить исходные строки и просто добавить вычисленную метрику.
- Когда вы делаете ранжирование, топы, проценты, доли, накопительные значения.
- Когда вы строите витрину “детализация + сводка” в одном запросе.
“Хитрости” и практические паттерны (и почему они не магия)
Паттерн: “агрегат в HAVING” вместо “агрегат в WHERE” через подзапрос
Если вам нужно отфильтровать строки, но условие зависит от агрегата, HAVING решает задачу. Но иногда удобнее вынести агрегат в CTE.
Пример: фильтровать пользователей, у которых было больше 5 заказов за период.
Вариант с HAVING:
SELECT
user_id
FROM orders
WHERE order_date >= DATE '2026-07-01'
AND order_date < DATE '2026-08-01'
GROUP BY user_id
HAVING COUNT(*) > 5;
Вариант с CTE (часто удобен, когда метрик несколько):
WITH user_counts AS (
SELECT
user_id,
COUNT(*) AS cnt
FROM orders
WHERE order_date >= DATE '2026-07-01'
AND order_date < DATE '2026-08-01'
GROUP BY user_id
)
SELECT user_id
FROM user_counts
WHERE cnt > 5;
Результат эквивалентен, но CTE упрощает чтение сложных отчётов и может помочь в отладке.
Паттерн: условные метрики с фильтром агрегата (если поддерживается)
В некоторых СУБД есть синтаксис FILTER (WHERE ...) для агрегатов. Тогда условная агрегация становится чище:
SELECT
user_id,
COUNT(*) FILTER (WHERE status = 'success') AS successful_orders,
SUM(amount) FILTER (WHERE status = 'success') AS successful_amount
FROM orders
GROUP BY user_id;
Это не везде поддерживается одинаково, но сам подход полезно держать в голове: условие относится к агрегату, а не к строкам во WHERE, когда вы хотите сохранить все строки для других вычислений.
Паттерн: “сначала агрегировать, потом ранжировать”
Частая логическая ошибка: пытаться ранжировать по сумме, не посчитав её. В таких случаях почти всегда нужен шаг агрегации:
- агрегат по нужным ключам;
- затем
ROW_NUMBER()/RANK()по агрегированным значениям; - затем фильтр по позиции.
Это один из тех моментов, где CTE — не “красота”, а способ не потерять смысл.
Типовые ошибки в отчетных запросах (которые встречаются в проде)
Ошибка №5: смешивание разных уровней детализации
Пример концептуально неверен: вы хотите показать средний чек, но при этом группируете по слишком детальным ключам.
SELECT
user_id,
product_id,
AVG(amount) AS avg_check
FROM sales
GROUP BY user_id, product_id;
Если бизнес-логика ожидает “средний чек по пользователю”, то группировка не должна включать product_id. SQL корректен по синтаксису, но неверен по смыслу.
Выход: всегда спрашивать себя: на каком уровне должна быть итоговая строка результата?
Ошибка №6: двойной подсчёт при JOIN
Когда соединяете таблицы, легко получить эффект “умножения строк” и завышенные суммы.
Например, если есть sales и discounts, где скидки на одну продажу, но в данных присутствуют несколько скидок, то SUM(amount) может стать больше исходного.
В таких кейсах решения разные:
- нормализовать join (например, брать только одну запись скидок на продажу);
- агрегировать перед join;
- использовать
DISTINCTтам, где это уместно
Комментарии
Пока нет комментариев