GROUP BY в SQL: группировка данных на примерах

GROUP BY в SQL: группировка данных на примерах Полезное

GROUP BY (группировка в SQL) — это оператор, который объединяет строки с одинаковым значением в указанных столбцах в одну группу, чтобы посчитать по каждой группе агрегат: количество, сумму, среднее, минимум или максимум. Без GROUP BY агрегатная функция считает по всей таблице сразу, с ним — отдельно по каждой группе.

Разберу оператор на одной игрушечной таблице продаж: покажу данные, соберу группировку с COUNT, SUM, AVG, MIN и MAX, разведу HAVING и WHERE и покажу три частые ошибки с реальным выводом. Примеры на стандартном SQL, поведение конкретной СУБД помечаю отдельно.

Синтаксис GROUP BY

Полная форма запроса с группировкой выглядит так:

SELECT   столбцы_группировки, агрегат(...)
FROM     таблица
WHERE    фильтр_строк          -- до группировки
GROUP BY столбцы_группировки
HAVING   фильтр_групп          -- после группировки, по агрегатам
ORDER BY столбцы;              -- сортировка результата

Важно понимать логический порядок выполнения, он объясняет почти все ошибки новичков: сначала FROM, потом WHERE (отсев строк), затем GROUP BY (сборка групп), потом HAVING (отсев групп), затем SELECT и в конце ORDER BY.

Из этого порядка следует главное правило: в SELECT можно ставить только столбцы из GROUP BY и агрегатные функции. Любой другой столбец после группировки не имеет одного значения на группу, и СУБД не знает, какое из значений вернуть.

Таблица для примеров

Заведу таблицу sales — простое представление продаж по трем точкам. Столбцы: место продажи, продукт, цена в рублях и время продажи.

CREATE TABLE sales (
    id       INTEGER PRIMARY KEY,
    location TEXT,
    product  TEXT,
    price    INTEGER,      -- цена в рублях
    sold_at  TIMESTAMP
);

INSERT INTO sales (id, location, product, price, sold_at) VALUES
(1, 'HQ',         'coffee', 200, '2026-09-14 09:15'),
(2, 'HQ',         'bagel',  120, '2026-09-14 09:40'),
(3, 'HQ',         'coffee', 200, '2026-09-13 10:05'),
(4, 'HQ',         'bagel',  120, '2026-09-12 11:20'),
(5, 'Downtown',   'coffee', 220, '2026-09-14 08:30'),
(6, 'Downtown',   'bagel',  130, '2026-09-13 12:10'),
(7, '1st Street', 'coffee', 210, '2026-09-14 14:00'),
(8, '1st Street', 'coffee', 210, '2026-09-12 15:45');

Восемь строк: четыре продажи в HQ, по две в Downtown и на 1st Street. Вот содержимое таблицы:

 id | location   | product | price | sold_at
----+------------+---------+-------+------------------
  1 | HQ         | coffee  |   200 | 2026-09-14 09:15
  2 | HQ         | bagel   |   120 | 2026-09-14 09:40
  3 | HQ         | coffee  |   200 | 2026-09-13 10:05
  4 | HQ         | bagel   |   120 | 2026-09-12 11:20
  5 | Downtown   | coffee  |   220 | 2026-09-14 08:30
  6 | Downtown   | bagel   |   130 | 2026-09-13 12:10
  7 | 1st Street | coffee  |   210 | 2026-09-14 14:00
  8 | 1st Street | coffee  |   210 | 2026-09-12 15:45

Группировка с агрегатами

Сначала посчитаю число продаж в каждой точке. COUNT(*) считает строки внутри каждой группы:

SELECT location, COUNT(*) AS number_of_sales
FROM sales
GROUP BY location;
 location   | number_of_sales
------------+-----------------
 HQ         |               4
 Downtown   |               2
 1st Street |               2

GROUP BY взял восемь строк, нашел три уникальных значения location и по каждому посчитал строки. Порядок строк в выводе без ORDER BY не гарантирован — к этому вернусь в разделе про ошибки.

SUM складывает значения столбца в группе. Считаю выручку по точкам и сразу сортирую по убыванию:

SELECT location, SUM(price) AS revenue
FROM sales
GROUP BY location
ORDER BY revenue DESC;
 location   | revenue
------------+---------
 HQ         |     640
 1st Street |     420
 Downtown   |     350

AVG, MIN и MAX работают так же и сочетаются в одном запросе. AVG возвращает дробное число, поэтому оборачиваю его в ROUND — без этого форматирование дробной части зависит от СУБД:

SELECT location,
       ROUND(AVG(price), 2) AS avg_price,
       MIN(price)           AS min_price,
       MAX(price)           AS max_price
FROM sales
GROUP BY location
ORDER BY location;
 location   | avg_price | min_price | max_price
------------+-----------+-----------+-----------
 1st Street |    210.00 |       210 |       210
 Downtown   |    175.00 |       130 |       220
 HQ         |    160.00 |       120 |       200

Группировать можно сразу по нескольким столбцам — тогда группа задается комбинацией их значений. Разложу продажи по паре «точка + продукт»:

SELECT location, product,
       COUNT(*)   AS sales_count,
       SUM(price) AS revenue
FROM sales
GROUP BY location, product
ORDER BY location, product;
 location   | product | sales_count | revenue
------------+---------+-------------+---------
 1st Street | coffee  |           2 |     420
 Downtown   | bagel   |           1 |     130
 Downtown   | coffee  |           1 |     220
 HQ         | bagel   |           2 |     240
 HQ         | coffee  |           2 |     400

Вместо трех групп по точкам получилось пять групп по уникальным парам значений. Если оставить в SELECT только один столбец группировки без агрегатов, GROUP BY вернет уникальные значения — в этом частном случае он ведет себя как DISTINCT.

HAVING и WHERE — в чем разница

Оба слова фильтруют, но на разных этапах. WHERE отсекает исходные строки до группировки и не умеет работать с агрегатами. HAVING отсекает уже готовые группы по значению агрегата. Это прямое следствие порядка выполнения: WHERE отрабатывает раньше GROUP BY, HAVING — позже.

Признак WHERE HAVING
Когда фильтрует до группировки, по строкам после группировки, по группам
С чем работает значения столбцов исходных строк результаты агрегатов и столбцы группировки
Агрегаты внутри условия нельзя можно и нужно
Требует GROUP BY нет как правило да
Порядок выполнения раньше позже

Пример на HAVING: оставить только точки, где выручка превысила 400. Условие стоит на агрегате SUM(price), поэтому ему место в HAVING, а не в WHERE:

SELECT location, SUM(price) AS revenue
FROM sales
GROUP BY location
HAVING SUM(price) > 400
ORDER BY revenue DESC;
 location   | revenue
------------+---------
 HQ         |     640
 1st Street |     420

Downtown с выручкой 350 в результат не попал. Часто нужны оба фильтра сразу: WHERE сужает строки до группировки, HAVING — группы после. Посчитаю продажи только кофе и оставлю точки, где таких продаж хотя бы две:

SELECT location, COUNT(*) AS coffee_sales
FROM sales
WHERE product = 'coffee'
GROUP BY location
HAVING COUNT(*) >= 2
ORDER BY location;
 location   | coffee_sales
------------+--------------
 1st Street |            2
 HQ         |            2

WHERE product = 'coffee' оставил пять строк (HQ — 2, Downtown — 1, 1st Street — 2), затем HAVING отсеял Downtown с единственной продажей кофе.

Три частые ошибки

Ошибка 1: столбец в SELECT не в GROUP BY и не в агрегате. Пытаюсь вывести product рядом с COUNT(*), но группирую только по location:

SELECT location, product, COUNT(*)
FROM sales
GROUP BY location;

Результат — ошибка. В PostgreSQL:

ERROR:  column "sales.product" must appear in the GROUP BY clause
or be used in an aggregate function

В MySQL с включенным по умолчанию режимом ONLY_FULL_GROUP_BY сообщение похоже: ERROR 1055 (42000): ... nonaggregated column 'sales.product' .... А вот SQLite и MySQL без ONLY_FULL_GROUP_BY запрос не отклонят — они молча вернут произвольное значение product из группы (правдоподобный, но неверный результат), и это опаснее явной ошибки. Причина: у группы HQ два разных продукта, и СУБД либо не знает, какой вернуть, либо берет случайный. Исправление — либо добавить столбец в группировку, либо агрегировать его:

SELECT location, product, COUNT(*)
FROM sales
GROUP BY location, product;   -- product теперь часть ключа группы

Ошибка 2: условие на агрегат в WHERE. Фильтрую выручку через WHERE, а не HAVING:

SELECT location, SUM(price) AS revenue
FROM sales
WHERE SUM(price) > 400
GROUP BY location;

WHERE выполняется до группировки, когда агрегатов еще нет, поэтому запрос падает. PostgreSQL: ERROR: aggregate functions are not allowed in WHERE. MySQL: ERROR 1111 (HY000): Invalid use of group function. Исправление — перенести условие в HAVING, как в примере выше.

Ошибка 3: расчет на то, что GROUP BY сам сортирует. Читаю результат группировки, полагаясь на «привычный» порядок:

SELECT location, COUNT(*) AS n
FROM sales
GROUP BY location;   -- порядок строк не гарантирован

Современные PostgreSQL и MySQL 8 не обещают никакой сортировки по GROUP BY, порядок может измениться от запроса к запросу и от версии. Исправление — задавать порядок явно:

SELECT location, COUNT(*) AS n
FROM sales
GROUP BY location
ORDER BY location;

Выводы

  • GROUP BY объединяет строки с одинаковыми значениями выбранных столбцов в группы и позволяет посчитать по каждой группе агрегат.
  • В SELECT при группировке допустимы только столбцы из GROUP BY и агрегатные функции — остальное вызовет ошибку.
  • Основные агрегаты: COUNT (число строк), SUM (сумма), AVG (среднее), MIN и MAX (крайние значения); их можно сочетать в одном запросе.
  • WHERE фильтрует строки до группировки и не работает с агрегатами, HAVING фильтрует готовые группы по значению агрегата.
  • Группировка по нескольким столбцам дает группы по комбинации значений; порядок вывода без ORDER BY не гарантирован.

Где применяется / связь с практикой

Группировка — основа почти любой аналитики на SQL: отчеты по выручке, количеству заказов, среднему чеку, активности пользователей по дням. Как только в задаче звучит «по каждому» или «в разрезе», за этим стоит GROUP BY с агрегатами и, часто, HAVING для отбора значимых групп.

Освойте тему на практике

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

Смежные темы: Команда Select в SQL, SQL для начинающих: что нужно знать о языке и его применении, Изучение SQL-команд на базе MySQL.

FAQ

Можно ли использовать GROUP BY без агрегатных функций?
Да. Тогда запрос вернет уникальные комбинации значений столбцов группировки — результат совпадет с SELECT DISTINCT по тем же столбцам.

Чем GROUP BY отличается от DISTINCT?
DISTINCT просто убирает повторяющиеся строки результата. GROUP BY формирует группы, к которым можно применить агрегаты и отбор через HAVING, — без этого на одном столбце их выводы совпадают, но возможности у GROUP BY шире.

Как сгруппировать по дате, если в столбце хранится дата со временем?
Нужно группировать по вычисляемому значению: GROUP BY CAST(sold_at AS DATE) или через функцию извлечения даты. Конкретная функция зависит от СУБД (например, DATE(sold_at) в MySQL, sold_at::date в PostgreSQL).

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)