$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогSQL-агрегации без страха: GROUP BY, HAVING, оконные функции и хитрости

SQL-агрегации без страха: GROUP BY, HAVING, оконные функции и хитрости

$ sudo teach IT
·19 июля 2026 г.·10 мин·54
SQL-агрегации без страха: GROUP BY, HAVING, оконные функции и хитрости

Построим понятный ментальный модель для агрегатов и условных группировок. Разберём оконные функции и типовые ошибки в запросах отчётов.

Содержание
Ментальная модель: что такое агрегат и где он «живёт»Агрегация как преобразование уровня строк в уровень группПочему это важно: ограничения на SELECT при наличии GROUP BYGROUP BY: базовые сценарии и подводные камниГруппировка по нескольким полямNULL в группировках: отдельная категория или игнорированиеОшибка №1: путаница между WHERE и HAVINGМелкая, но важная хитрость: HAVING + агрегаты vs HAVING + алиасыHAVING: условные группировки без страхаФильтрация групп по количествуФильтрация групп по вычисленным метрикамПодводный камень: HAVING не заменяет WHEREУсловная группировка: CASE, “подсчёт только нужного”, и почему это работаетВариант 1: COUNT + CASEВариант 2: SUM + CASE для суммирования по условиюОшибка №2: забывают ELSE и получают NULL в SUMКогда агрегаты нужны вместе с деталями: “один запрос — два уровня”Оконные функции: когда GROUP BY уже недостаточноОконная функция отличается от агрегата тем, что сохраняет строкиПример: сумма продаж по пользователю без GROUP BYТиповые оконные сценарии для отчётов1) Ранжирование: ROWNUMBER, RANK, DENSERANKОшибка №3: путаница ROWNUMBER и RANK2) Доля от общей суммы3) Накопительные суммы (running totals)Ошибка №4: отсутствует ORDER BY в окне для накопительных метрикFrame в оконных функциях: тонкости, которые реально всплываютСравнение подходов: GROUP BY + JOIN vs оконные функцииКогда лучше GROUP BYКогда лучше оконные функции“Хитрости” и практические паттерны (и почему они не магия)Паттерн: “агрегат в HAVING” вместо “агрегат в WHERE” через подзапросПаттерн: условные метрики с фильтром агрегата (если поддерживается)Паттерн: “сначала агрегировать, потом ранжировать”Типовые ошибки в отчетных запросах (которые встречаются в проде)Ошибка №5: смешивание разных уровней детализацииОшибка №6: двойной подсчёт при JOIN

Агрегации в SQL — это тот участок языка, где многие либо «механически копируют запросы», либо начинают бояться. Причина обычно не в сложности синтаксиса, а в отсутствии устойчивой ментальной модели: что именно агрегируется, когда применяется фильтрация, и почему иногда запросы ведут себя «как будто логика сломалась».

В этой статье разберём агрегации и условные группировки так, чтобы вы могли уверенно строить отчёты любой сложности: от простых сводных таблиц до аналитических запросов с оконными функциями. Отдельно разберём типовые ошибки, которые чаще всего встречаются в реальных проектах.


Ментальная модель: что такое агрегат и где он «живёт»

Агрегация как преобразование уровня строк в уровень групп

Представьте таблицу как поток строк. Оператор агрегирования (в SQL это COUNT, SUM, AVG, MIN, MAX и т.д.) переводит данные с «уровня строк» на «уровень групп».

  • До GROUP BY вы работаете с конкретными строками.
  • После GROUP BY вы получаете по одной строке результата на каждую группу.

Пример. Есть продажи:

code
sales(user_id, product_id, sale_date, amount)

Если вы хотите посчитать суммарные продажи по пользователям, вы просите SQL:

  1. сгруппировать строки по user_id;
  2. агрегировать amount внутри каждой группы.

Тогда результат будет иметь одну строку на каждого пользователя.

code
SELECT
  user_id,
  SUM(amount) AS total_amount
FROM sales
GROUP BY user_id;

Почему это важно: ограничения на SELECT при наличии GROUP BY

Когда используется GROUP BY, в SELECT можно выводить:

  • только поля, которые участвуют в группировке;
  • или агрегаты (вычисления внутри группы).

Попытка вывести «не сгруппированное» поле почти всегда приводит к ошибке или к неочевидному поведению (в зависимости от режима SQL). Например:

code
SELECT
  user_id,
  sale_date,          -- <- нельзя без агрегирования
  SUM(amount) AS total_amount
FROM sales
GROUP BY user_id;

Ментальная модель здесь такая: sale_date внутри группы множества значений. Нужно либо выбрать правило (агрегат/функция), либо изменить уровень детализации.


GROUP BY: базовые сценарии и подводные камни

Группировка по нескольким полям

Часто отчёт строится не по одному ключу. Например, сумма продаж по пользователю и продукту:

code
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 в одном столбце образуют группу. Это значит, что запрос:

code
SELECT product_id, COUNT(*)
FROM sales
GROUP BY product_id;

может вернуть строку с product_id = NULL.

Иногда это поведение нежелательно: вы хотите считать такие строки как «неизвестно» и, например, не включать их в отчёт. Тогда используйте WHERE, а не HAVING (об этом дальше).

Ошибка №1: путаница между WHERE и HAVING

Это один из главных источников проблем. Разница:

  • WHERE фильтрует строки до группировки.
  • HAVING фильтрует группы после группировки.

Пример. Нужно посчитать пользователей, у которых суммарная сумма продаж больше 1000.

Правильно:

code
SELECT
  user_id,
  SUM(amount) AS total_amount
FROM sales
GROUP BY user_id
HAVING SUM(amount) > 1000;

Неправильно (или невозможно корректно выразить с тем же смыслом) так:

code
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. Но полагаться на это без понимания конкретной СУБД — риск. Надёжный вариант — повторить выражение:

code
HAVING SUM(amount) > 1000

HAVING: условные группировки без страха

Фильтрация групп по количеству

Например, найти продукты, у которых было хотя бы 10 продаж:

code
SELECT
  product_id,
  COUNT(*) AS cnt
FROM sales
GROUP BY product_id
HAVING COUNT(*) >= 10;

Это типичный запрос для отчётов качества данных (например, «достаточно данных для анализа») или бизнес-условий.

Фильтрация групп по вычисленным метрикам

Например, средний чек по пользователю должен быть не меньше 50:

code
SELECT
  user_id,
  AVG(amount) AS avg_check
FROM sales
GROUP BY user_id
HAVING AVG(amount) >= 50;

Подводный камень: HAVING не заменяет WHERE

Если условие относится к отдельным строкам (например, продажи до конкретной даты), то логика должна быть в WHERE.

Допустим, нужно посчитать пользователей по продажам за июль:

code
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

Например, посчитать количество успешных заказов и общее количество:

code
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 для суммирования по условию

Сумма только успешных продаж:

code
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

Если использовать:

code
SUM(CASE WHEN status = 'success' THEN amount END)

то для неподходящих строк будет NULL. SUM обычно игнорирует NULL, что иногда ок, но в отчётах это может ломать ожидания (например, когда нужна явная нулевая сумма). Поэтому выбор ELSE 0 делает смысл однозначным.


Когда агрегаты нужны вместе с деталями: “один запрос — два уровня”

Классическая проблема: хочется вывести детали и при этом агрегаты.

Например: список пользователей и в каждой строке — их суммарные продажи. На уровне пользователя это нормально, но вы хотите видеть, возможно, ещё и дополнительные поля: “последняя покупка”, “номер месяца”, “самая крупная покупка”.

Самый простой путь — перестроить запрос в два этапа: агрегат по одному уровню, затем соединение. Но в аналитике часто удобнее использовать оконные функции.


Оконные функции: когда GROUP BY уже недостаточно

Оконная функция отличается от агрегата тем, что сохраняет строки

Условно:

  • GROUP BY уменьшает количество строк (сворачивает в группы).
  • Оконная функция считает значение, но не сворачивает строки — каждая строка остаётся на месте.

Синтаксис:

code
<агрегат_или_аналитическая_функция> OVER (
  PARTITION BY ...
  ORDER BY ...
  <frame>
)
  • PARTITION BY задаёт, по каким ключам “разделить” данные на окна (аналог групп).
  • ORDER BY задаёт порядок в окне (для ранжирования, накопительных функций, “текущего момента”).
  • frame (опционально) управляет диапазоном для накопительных окон.

Пример: сумма продаж по пользователю без GROUP BY

Вернёмся к продажам. Хотим показать каждой строке продажи сумму по пользователю:

code
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” внутри категории.

Например: лучший продукт по сумме продаж в рамках каждого пользователя.

Схема: считаем сумму по продукту, ранжируем внутри пользователя, берём первые.

code
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) Доля от общей суммы

Отчёт “сколько процентов от суммы” часто удобнее делать оконной функцией:

code
SELECT
  product_id,
  amount,
  SUM(amount) OVER () AS total_all,
  amount * 1.0 / SUM(amount) OVER () AS share_of_total
FROM sales;

Но это пример на уровне строк. Обычно в отчёте агрегат по продукту:

code
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)

Например, накопленный доход по дням:

code
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 строк:

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

code
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 (часто удобен, когда метрик несколько):

code
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 ...) для агрегатов. Тогда условная агрегация становится чище:

code
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: смешивание разных уровней детализации

Пример концептуально неверен: вы хотите показать средний чек, но при этом группируете по слишком детальным ключам.

code
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 там, где это уместно

Войдите, чтобы поставить лайк и оставить комментарий.

Автор

$ sudo teach IT

SQL – для начинающих!

Курс по теме

SQL – для начинающих!

С нуля до уверенных запросов на SQL. Никакой теории без дела — только живые запросы, настоящие данные и готовность к работе с любой базой. Хватит гуглить «как написать JOIN» — разберись один раз и забудь.

Бесплатно
Открыть курс

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу
Простыми словами о защищенном соединении

Простыми словами о защищенном соединении

Базовый курс для обычных людей: что такое защищённое соединение, как оно устроено, что оно умеет и чего не умеет, как выбрать сервис и настроить его на своих устройствах.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу
Сервис, который не падает - как построить безопасную зашифрованную сеть

Сервис, который не падает - как построить безопасную зашифрованную сеть

Практический курс по безопасности сети: как собрать собственный сервис защищённого доступа - от пустого сервера до подписки с автовыбором - и, что важнее, как потом не уронить его своими руками.

Перейти к курсу

Другие статьи

SQL: диаграмма жизненного цикла индекса — от EXPLAIN до теста на регрессию производительности
диаграмма

SQL: диаграмма жизненного цикла индекса — от EXPLAIN до теста на регрессию производительности

Покажем практический процесс: как определить, какой индекс реально используется, когда он бесполезен, как проверить влияние после изменений схемы и как не словить деградацию запросов в будущем.

16 сентября 2026 г.
1050
SQL JOIN за 10 минут: наглядный гид с реальными примерами
join

SQL JOIN за 10 минут: наглядный гид с реальными примерами

Объясняем INNER, LEFT, RIGHT и FULL JOIN на живых таблицах — без воды и абстрактных схем, только запросы и результаты.

16 июля 2026 г.
680
Создание Telegram бота в 2026 легко и просто! Полный курсы!
создание

Создание Telegram бота в 2026 легко и просто! Полный курсы!

19 июня 2026 г.
1431
Как выбрать, куда идти в IT: веб, боты, данные — без хаоса
выбрать

Как выбрать, куда идти в IT: веб, боты, данные — без хаоса

Разберём самые понятные направления для новичка (веб, боты, аналитика/данные) и чем они отличаются по шагам обучения. В конце вы получите простой план, как проверить, что вам подходит, и какой стартовый путь выбрать.

5 октября 2026 г.
580
Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть
путь

Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть

Разберём структуру репозитория, как упаковать проект в понятную историю (проблема → решение → запуск → метрики качества) и какие артефакты повышают доверие: скринкасты, архитектурная схема, чек-лист продакшн-готовности.

17 сентября 2026 г.
1090
Как понять, какой язык программирования учить первым: простой тест и выбор
понять

Как понять, какой язык программирования учить первым: простой тест и выбор

Помогу новичку выбрать первый язык без стресса: объясню варианты по целям (веб, боты, анализ данных), что учить в первую очередь и как не потратить месяцы впустую. В конце — краткий план старта на ближайшие недели.

2 октября 2026 г.
630

Комментарии

Станьте первым в обсуждении

Поделитесь мнением о статье или задайте вопрос автору

Содержание

Ментальная модель: что такое агрегат и где он «живёт»Агрегация как преобразование уровня строк в уровень группПочему это важно: ограничения на SELECT при наличии GROUP BYGROUP BY: базовые сценарии и подводные камниГруппировка по нескольким полямNULL в группировках: отдельная категория или игнорированиеОшибка №1: путаница между WHERE и HAVINGМелкая, но важная хитрость: HAVING + агрегаты vs HAVING + алиасыHAVING: условные группировки без страхаФильтрация групп по количествуФильтрация групп по вычисленным метрикамПодводный камень: HAVING не заменяет WHEREУсловная группировка: CASE, “подсчёт только нужного”, и почему это работаетВариант 1: COUNT + CASEВариант 2: SUM + CASE для суммирования по условиюОшибка №2: забывают ELSE и получают NULL в SUMКогда агрегаты нужны вместе с деталями: “один запрос — два уровня”Оконные функции: когда GROUP BY уже недостаточноОконная функция отличается от агрегата тем, что сохраняет строкиПример: сумма продаж по пользователю без GROUP BYТиповые оконные сценарии для отчётов1) Ранжирование: ROWNUMBER, RANK, DENSERANKОшибка №3: путаница ROWNUMBER и RANK2) Доля от общей суммы3) Накопительные суммы (running totals)Ошибка №4: отсутствует ORDER BY в окне для накопительных метрикFrame в оконных функциях: тонкости, которые реально всплываютСравнение подходов: GROUP BY + JOIN vs оконные функцииКогда лучше GROUP BYКогда лучше оконные функции“Хитрости” и практические паттерны (и почему они не магия)Паттерн: “агрегат в HAVING” вместо “агрегат в WHERE” через подзапросПаттерн: условные метрики с фильтром агрегата (если поддерживается)Паттерн: “сначала агрегировать, потом ранжировать”Типовые ошибки в отчетных запросах (которые встречаются в проде)Ошибка №5: смешивание разных уровней детализацииОшибка №6: двойной подсчёт при JOIN