GROUP BY — это команда, которая превращает много обычных строк в аккуратную сводку по группам.
Без GROUP BY таблица отвечает на вопрос: «Какие строки у нас есть?»
С GROUP BY таблица отвечает на другой вопрос: «Какие итоги получаются по каждой группе?»
Например:
- сколько заказов сделал каждый клиент;
- сколько пользователей пришло из каждой страны;
- какая сумма продаж у каждой категории товаров;
- какая средняя зарплата в каждом отделе;
- какие теги в блоге используются чаще всего.
Проще говоря, GROUP BY нужен, когда ты хочешь не просто посмотреть данные, а посчитать статистику.
Простая идея GROUP BY
Представь таблицу заказов. В ней каждая строка — отдельный заказ:
| order_id |
customer_id |
amount |
| 1 |
101 |
500 |
| 2 |
102 |
300 |
| 3 |
101 |
700 |
| 4 |
103 |
200 |
| 5 |
101 |
100 |
Если сделать обычный SELECT, ты увидишь все заказы по отдельности.
Но если написать:
SELECT
customer_id,
COUNT(*) AS orders_count
FROM orders
GROUP BY customer_id;
то база сгруппирует строки по customer_id.
Результат:
| customer_id |
orders_count |
| 101 |
3 |
| 102 |
1 |
| 103 |
1 |
Пять строк превратились в три строки: по одной строке на каждого клиента.
Вот это и есть смысл GROUP BY: собрать одинаковые значения в группы и посчитать что-то внутри каждой группы.
Зачем нужен GROUP BY
GROUP BY почти всегда идёт рядом с агрегатными функциями.
Агрегатная функция — это функция, которая берёт несколько строк и возвращает одно итоговое значение.
Самые частые агрегатные функции:
| Функция |
Что делает |
COUNT(*) |
Считает количество строк |
SUM(column) |
Считает сумму |
AVG(column) |
Считает среднее значение |
MIN(column) |
Находит минимальное значение |
MAX(column) |
Находит максимальное значение |
Например, если у нас есть таблица товаров, можно посчитать статистику по каждой категории:
SELECT
category,
COUNT(*) AS products_count,
SUM(price) AS total_price,
AVG(price) AS avg_price,
MIN(price) AS min_price,
MAX(price) AS max_price
FROM products
GROUP BY category;
Такой запрос говорит базе:
Разбей товары по категориям, а потом для каждой категории посчитай количество, сумму, среднюю цену, минимальную и максимальную цену.
Результат может быть таким:
| category |
products_count |
total_price |
avg_price |
min_price |
max_price |
| books |
4 |
3200 |
800 |
500 |
1200 |
| games |
3 |
7500 |
2500 |
1500 |
3500 |
| courses |
2 |
10000 |
5000 |
4000 |
6000 |
Вместо списка отдельных товаров мы получили отчёт.
Базовый синтаксис GROUP BY
Самый простой вариант выглядит так:
SELECT
country,
COUNT(*) AS users_count
FROM users
GROUP BY country;
Разберём по шагам.
База берёт таблицу users, находит все строки, смотрит на значение в колонке country и собирает одинаковые страны вместе.
Если в таблице есть 100 пользователей из RU, 50 пользователей из US и 30 пользователей из DE, то получится три группы.
Потом COUNT(*) считает количество строк внутри каждой группы.
На выходе будет одна строка на каждую страну.
Пример с таблицей users
Есть таблица users:
| id |
name |
country |
| 1 |
Anna |
RU |
| 2 |
Bob |
US |
| 3 |
Vera |
RU |
| 4 |
Gleb |
RU |
| 5 |
Dan |
US |
Запрос:
SELECT
country,
COUNT(*) AS users_count
FROM users
GROUP BY country;
Результат:
| country |
users_count |
| RU |
3 |
| US |
2 |
Что произошло:
- строки с
RU попали в одну группу;
- строки с
US попали в другую группу;
COUNT(*) посчитал строки внутри каждой группы.
То есть GROUP BY country не показывает каждого пользователя отдельно. Он показывает итог по каждой стране.
GROUP BY по нескольким колонкам
Группировать можно не только по одной колонке.
Например, у пользователей есть страна и тариф:
| id |
country |
tier |
| 1 |
RU |
free |
| 2 |
RU |
paid |
| 3 |
RU |
free |
| 4 |
US |
free |
| 5 |
US |
paid |
| 6 |
US |
paid |
Можно посчитать пользователей по каждой паре country + tier:
SELECT
country,
tier,
COUNT(*) AS users_count
FROM users
GROUP BY country, tier
ORDER BY country, tier;
Результат:
| country |
tier |
users_count |
| RU |
free |
2 |
| RU |
paid |
1 |
| US |
free |
1 |
| US |
paid |
2 |
Здесь группа — это не просто страна. Группа — это комбинация значений.
То есть:
RU + free — одна группа;
RU + paid — другая группа;
US + free — третья группа;
US + paid — четвёртая группа.
Простое правило:
Чем больше колонок в GROUP BY, тем подробнее становится группировка.
Главное правило SELECT при GROUP BY
Это самый важный момент.
Когда в запросе есть GROUP BY, в SELECT можно указывать только:
- колонки, которые есть в
GROUP BY;
- агрегатные функции.
Например, так можно:
SELECT
country,
COUNT(*) AS users_count
FROM users
GROUP BY country;
Почему можно?
Потому что country есть в GROUP BY, а COUNT(*) — агрегатная функция.
А вот так нельзя:
SELECT
country,
name,
COUNT(*) AS users_count
FROM users
GROUP BY country;
Почему нельзя?
Потому что в группе RU может быть несколько пользователей: Anna, Vera, Gleb.
Какое имя должна показать база в колонке name?
Одно? Какое именно? Первое? Последнее? Случайное?
SQL не хочет угадывать. Поэтому и появляется ошибка: колонка должна либо участвовать в группировке, либо быть внутри агрегатной функции.
Правильно можно сделать так:
SELECT
country,
name,
COUNT(*) AS rows_count
FROM users
GROUP BY country, name;
Но теперь группировка изменилась. Группа — это уже не страна, а пара country + name.
Или можно так:
SELECT
country,
MAX(name) AS any_name,
COUNT(*) AS users_count
FROM users
GROUP BY country;
Но здесь MAX(name) не означает «главное имя». Для текста это просто одно из значений по правилу сортировки. Использовать такой приём стоит только тогда, когда ты понимаешь, зачем тебе это нужно.
Почему нельзя просто вывести любую колонку из группы
Представим группу по стране RU:
| id |
name |
country |
| 1 |
Anna |
RU |
| 3 |
Vera |
RU |
| 4 |
Gleb |
RU |
Если запрос такой:
SELECT
country,
name,
COUNT(*) AS users_count
FROM users
GROUP BY country;
то результат должен быть одной строкой на страну:
| country |
name |
users_count |
| RU |
? |
3 |
С country всё понятно: вся группа имеет значение RU.
С COUNT(*) тоже всё понятно: в группе 3 строки.
А вот с name непонятно. В группе три разных имени. Поэтому нормальная база данных не даст выполнить такой запрос.
Это не придирка SQL, а защита от странного отчёта.
GROUP BY и порядок выполнения запроса
Новичкам часто кажется, что SQL выполняется сверху вниз: сначала SELECT, потом FROM, потом GROUP BY.
На самом деле логический порядок другой.
Упрощённо запрос выполняется так:
FROM — откуда берём строки.
WHERE — какие строки оставляем до группировки.
GROUP BY — как собираем строки в группы.
HAVING — какие группы оставляем после группировки.
SELECT — что выводим в результате.
ORDER BY — как сортируем результат.
LIMIT — сколько строк оставляем.
Например:
SELECT
country,
COUNT(*) AS users_count
FROM users
WHERE registered_at >= DATE '2026-01-01'
GROUP BY country
HAVING COUNT(*) >= 10
ORDER BY users_count DESC
LIMIT 5;
Что здесь происходит:
- сначала берём пользователей из
users;
- оставляем только тех, кто зарегистрировался с
2026-01-01;
- группируем по стране;
- оставляем только страны, где пользователей минимум 10;
- выводим страну и количество пользователей;
- сортируем по количеству;
- берём первые 5 строк.
Это уже полноценный маленький отчёт.
WHERE и HAVING: в чём разница
WHERE и HAVING оба фильтруют данные, но делают это в разные моменты.
WHERE фильтрует строки до группировки.
HAVING фильтрует группы после группировки.
Например, нужно посчитать пользователей по странам, но брать только активных пользователей.
SELECT
country,
COUNT(*) AS active_users_count
FROM users
WHERE status = 'active'
GROUP BY country;
Здесь WHERE status = 'active' сначала убрал неактивных пользователей, и только потом база сгруппировала оставшиеся строки по странам.
А теперь другая задача: показать только те страны, где активных пользователей больше 100.
SELECT
country,
COUNT(*) AS active_users_count
FROM users
WHERE status = 'active'
GROUP BY country
HAVING COUNT(*) > 100;
Здесь:
WHERE оставляет только активных пользователей;
GROUP BY собирает их по странам;
HAVING оставляет только те группы, где COUNT(*) > 100.
Плохой вариант:
SELECT
country,
COUNT(*) AS users_count
FROM users
WHERE COUNT(*) > 100
GROUP BY country;
Так нельзя, потому что на этапе WHERE групп ещё нет. Значит, COUNT(*) ещё не посчитан.
Запомнить можно так:
WHERE — фильтр для строк.
HAVING — фильтр для групп.
GROUP BY и ORDER BY
После группировки результат можно отсортировать.
Например, найти топ-5 стран по количеству пользователей:
SELECT
country,
COUNT(*) AS users_count
FROM users
GROUP BY country
ORDER BY users_count DESC
LIMIT 5;
Здесь ORDER BY users_count DESC сортирует уже готовые группы: сначала страны с большим количеством пользователей, потом с меньшим.
Можно сортировать и прямо по агрегатной функции:
SELECT
country,
COUNT(*) AS users_count
FROM users
GROUP BY country
ORDER BY COUNT(*) DESC;
Но чаще удобнее дать агрегату понятное имя через AS, а потом сортировать по этому имени.
GROUP BY и DISTINCT
GROUP BY иногда путают с DISTINCT, потому что оба могут убрать повторяющиеся значения.
Но задачи у них разные.
DISTINCT нужен, когда ты хочешь получить список уникальных значений.
SELECT DISTINCT country
FROM users;
Результат:
А GROUP BY нужен, когда ты хочешь не просто уникальные значения, а ещё и расчёты по каждой группе.
SELECT
country,
COUNT(*) AS users_count
FROM users
GROUP BY country;
Результат:
| country |
users_count |
| RU |
100 |
| US |
50 |
| DE |
30 |
Простая разница:
DISTINCT отвечает: «Какие разные значения есть?»
GROUP BY отвечает: «Какие группы есть и какие итоги у каждой группы?»
Если нужен только список стран — бери DISTINCT.
Если нужно количество пользователей по странам — бери GROUP BY.
COUNT(*) и COUNT(column)
Это важная тема, потому что новички часто думают, что COUNT(*) и COUNT(email) делают одно и то же.
На самом деле нет.
COUNT(*) считает все строки в группе.
COUNT(column) считает только строки, где в этой колонке не NULL.
Пример:
Запрос:
SELECT
country,
COUNT(*) AS rows_count,
COUNT(email) AS emails_count
FROM users
GROUP BY country;
Результат:
| country |
rows_count |
emails_count |
| RU |
3 |
2 |
| US |
2 |
1 |
Для RU всего 3 строки, но email есть только у 2 пользователей.
Поэтому:
- если нужно посчитать строки — используй
COUNT(*);
- если нужно посчитать заполненные значения в колонке — используй
COUNT(column).
GROUP BY и NULL
NULL в GROUP BY ведёт себя особым образом: все строки с NULL в группируемой колонке попадают в одну группу.
Например:
| id |
country |
| 1 |
RU |
| 2 |
NULL |
| 3 |
US |
| 4 |
NULL |
| 5 |
RU |
Запрос:
SELECT
country,
COUNT(*) AS users_count
FROM users
GROUP BY country
ORDER BY country;
Результат может быть таким:
| country |
users_count |
| RU |
2 |
| US |
1 |
| NULL |
2 |
То есть NULL не исчезает сам по себе. Он образует отдельную группу.
Если не хочешь видеть пользователей без страны, добавь фильтр до группировки:
SELECT
country,
COUNT(*) AS users_count
FROM users
WHERE country IS NOT NULL
GROUP BY country
ORDER BY country;
GROUP BY по датам
С датами нужно быть аккуратным.
Если сгруппировать по полной дате и времени, можно получить слишком много групп.
Например:
SELECT
created_at,
COUNT(*) AS orders_count
FROM orders
GROUP BY created_at;
Если created_at содержит точное время до секунд, почти каждый заказ может оказаться в отдельной группе.
Для отчётов чаще группируют не по полной дате, а по периоду: день, месяц, год.
Например, заказы по дням:
SELECT
DATE_TRUNC('day', created_at) AS day,
COUNT(*) AS orders_count
FROM orders
GROUP BY 1
ORDER BY 1;
Заказы по месяцам:
SELECT
DATE_TRUNC('month', created_at) AS month,
COUNT(*) AS orders_count
FROM orders
GROUP BY 1
ORDER BY 1;
DATE_TRUNC('month', created_at) обрезает дату до начала месяца.
Например:
| created_at |
month |
| 2026-03-15 10:30:00 |
2026-03-01 00:00:00 |
| 2026-03-22 18:10:00 |
2026-03-01 00:00:00 |
| 2026-04-02 09:00:00 |
2026-04-01 00:00:00 |
Так мартовские даты попадут в одну группу, апрельские — в другую.
GROUP BY по месяцу: частая ловушка
Иногда хочется написать так:
SELECT
EXTRACT(MONTH FROM created_at) AS month,
COUNT(*) AS orders_count
FROM orders
GROUP BY 1
ORDER BY 1;
Такой запрос сгруппирует заказы по номеру месяца: 1, 2, 3 и так далее.
Но есть проблема: январь 2025 года и январь 2026 года попадут в одну группу.
Если данные только за один год — всё нормально. Если данных несколько лет — отчёт станет неправильным.
Лучше добавить год:
SELECT
EXTRACT(YEAR FROM created_at) AS year,
EXTRACT(MONTH FROM created_at) AS month,
COUNT(*) AS orders_count
FROM orders
GROUP BY 1, 2
ORDER BY 1, 2;
Или использовать DATE_TRUNC:
SELECT
DATE_TRUNC('month', created_at) AS month,
COUNT(*) AS orders_count
FROM orders
GROUP BY 1
ORDER BY 1;
Для отчётов по времени DATE_TRUNC часто удобнее и безопаснее.
GROUP BY и SUM
Один из самых частых сценариев — сумма по группам.
Например, нужно посчитать выручку по каждому клиенту:
SELECT
customer_id,
SUM(amount) AS total_amount
FROM orders
GROUP BY customer_id
ORDER BY total_amount DESC;
Результат:
| customer_id |
total_amount |
| 101 |
1300 |
| 102 |
800 |
| 103 |
200 |
Так можно быстро понять, какие клиенты принесли больше всего денег.
GROUP BY и AVG
AVG считает среднее значение внутри группы.
Например, средний чек по каждому клиенту:
SELECT
customer_id,
AVG(amount) AS avg_order_amount
FROM orders
GROUP BY customer_id
ORDER BY avg_order_amount DESC;
Если у клиента было три заказа на 500, 700 и 100, средний чек будет:
SELECT (500 + 700 + 100) / 3.0 AS avg_amount;
Результат:
| avg_amount |
| 433.3333333333333333 |
В реальных отчётах среднее часто округляют:
SELECT
customer_id,
ROUND(AVG(amount), 2) AS avg_order_amount
FROM orders
GROUP BY customer_id
ORDER BY avg_order_amount DESC;
GROUP BY и MIN/MAX
MIN и MAX помогают найти минимальное и максимальное значение внутри каждой группы.
Например, первый и последний заказ клиента:
SELECT
customer_id,
MIN(created_at) AS first_order_at,
MAX(created_at) AS last_order_at
FROM orders
GROUP BY customer_id;
Результат:
| customer_id |
first_order_at |
last_order_at |
| 101 |
2026-01-10 12:00:00 |
2026-03-05 09:30:00 |
| 102 |
2026-02-01 15:20:00 |
2026-02-18 11:10:00 |
Такой запрос часто используют, чтобы понять:
- когда пользователь впервые совершил действие;
- когда он был активен в последний раз;
- какой минимальный или максимальный показатель есть в группе.
Полезный пример: отчёт по заказам клиентов
Допустим, есть таблица orders:
| id |
customer_id |
created_at |
amount |
| 1 |
101 |
2026-01-10 12:00:00 |
500 |
| 2 |
102 |
2026-01-12 15:00:00 |
300 |
| 3 |
101 |
2026-02-03 10:00:00 |
700 |
| 4 |
103 |
2026-02-20 09:30:00 |
200 |
| 5 |
101 |
2026-03-01 18:00:00 |
100 |
Можно собрать нормальный отчёт по каждому клиенту:
SELECT
customer_id,
COUNT(*) AS orders_count,
SUM(amount) AS total_amount,
ROUND(AVG(amount), 2) AS avg_amount,
MIN(created_at) AS first_order_at,
MAX(created_at) AS last_order_at
FROM orders
GROUP BY customer_id
ORDER BY total_amount DESC;
Результат:
| customer_id |
orders_count |
total_amount |
avg_amount |
first_order_at |
last_order_at |
| 101 |
3 |
1300 |
433.33 |
2026-01-10 12:00:00 |
2026-03-01 18:00:00 |
| 102 |
1 |
300 |
300.00 |
2026-01-12 15:00:00 |
2026-01-12 15:00:00 |
| 103 |
1 |
200 |
200.00 |
2026-02-20 09:30:00 |
2026-02-20 09:30:00 |
Вот в этом месте GROUP BY начинает ощущаться не как странная SQL-команда, а как инструмент аналитики.
У тебя была таблица отдельных заказов. А получился отчёт по клиентам.
Частые ошибки новичков
Ошибка 1. Добавить в SELECT колонку, которой нет в GROUP BY
Плохой вариант:
SELECT
country,
name,
COUNT(*) AS users_count
FROM users
GROUP BY country;
Проблема: в одной стране может быть много имён. База не знает, какое имя показать.
Правильный вариант:
SELECT
country,
COUNT(*) AS users_count
FROM users
GROUP BY country;
Или, если нужна группировка ещё и по имени:
SELECT
country,
name,
COUNT(*) AS users_count
FROM users
GROUP BY country, name;
Ошибка 2. Фильтровать агрегаты через WHERE
Плохой вариант:
SELECT
country,
COUNT(*) AS users_count
FROM users
WHERE COUNT(*) > 100
GROUP BY country;
Так нельзя, потому что WHERE работает до группировки.
Правильный вариант:
SELECT
country,
COUNT(*) AS users_count
FROM users
GROUP BY country
HAVING COUNT(*) > 100;
HAVING работает после группировки и умеет фильтровать по агрегатам.
Ошибка 3. Путать COUNT(*) и COUNT(column)
SELECT
country,
COUNT(*) AS rows_count,
COUNT(email) AS emails_count
FROM users
GROUP BY country;
COUNT(*) считает все строки.
COUNT(email) считает только строки, где email не NULL.
Если в колонке много пустых значений, результаты будут сильно отличаться.
Ошибка 4. Группировать по слишком точному значению
Например:
SELECT
created_at,
COUNT(*) AS orders_count
FROM orders
GROUP BY created_at;
Если в created_at есть часы, минуты и секунды, групп получится слишком много.
Для отчёта по месяцам лучше так:
SELECT
DATE_TRUNC('month', created_at) AS month,
COUNT(*) AS orders_count
FROM orders
GROUP BY 1
ORDER BY 1;
Ошибка 5. Забыть про NULL
Если группировать по колонке, где есть NULL, все NULL попадут в одну отдельную группу.
Если такие строки не нужны, фильтруй их заранее:
SELECT
country,
COUNT(*) AS users_count
FROM users
WHERE country IS NOT NULL
GROUP BY country;
Ошибка 6. Группировать по месяцу без года
Плохой вариант для данных за несколько лет:
SELECT
EXTRACT(MONTH FROM created_at) AS month,
SUM(amount) AS total_amount
FROM orders
GROUP BY 1
ORDER BY 1;
Так январь разных лет смешается.
Лучше:
SELECT
EXTRACT(YEAR FROM created_at) AS year,
EXTRACT(MONTH FROM created_at) AS month,
SUM(amount) AS total_amount
FROM orders
GROUP BY 1, 2
ORDER BY 1, 2;
Или:
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(amount) AS total_amount
FROM orders
GROUP BY 1
ORDER BY 1;
GROUP BY в PostgreSQL и MySQL
В PostgreSQL правило строгое: если колонка стоит в SELECT, она должна быть либо в GROUP BY, либо внутри агрегатной функции.
Например, такой запрос PostgreSQL не пропустит:
SELECT
country,
name,
COUNT(*) AS users_count
FROM users
GROUP BY country;
И это хорошо: база защищает тебя от случайного и непонятного результата.
В MySQL раньше можно было встретить более мягкое поведение: база могла вернуть какое-то значение из группы. Но в строгом режиме с ONLY_FULL_GROUP_BY MySQL тоже требует нормальной логики группировки.
Практический совет простой:
Пиши запросы так, как будто база всегда строгая. Это защитит от странных отчётов и неприятных сюрпризов.
Как читать запрос с GROUP BY
Возьмём запрос:
SELECT
category,
COUNT(*) AS products_count,
AVG(price) AS avg_price
FROM products
WHERE is_active = true
GROUP BY category
HAVING COUNT(*) >= 5
ORDER BY avg_price DESC;
Читать его удобно не сверху вниз, а по смыслу:
- Берём таблицу
products.
- Оставляем только активные товары.
- Группируем товары по категории.
- Для каждой категории считаем количество и среднюю цену.
- Оставляем только категории, где товаров минимум 5.
- Сортируем категории по средней цене от большей к меньшей.
Такой способ чтения помогает не путаться, особенно когда запрос становится длиннее.
Когда использовать GROUP BY
Используй GROUP BY, когда в задаче звучат слова:
- «по каждому»;
- «по категориям»;
- «по странам»;
- «по месяцам»;
- «сколько всего»;
- «сумма по»;
- «среднее по»;
- «минимум и максимум по»;
- «топ по количеству».
Например:
«Сколько заказов у каждого клиента?» — почти точно нужен GROUP BY customer_id.
SELECT
customer_id,
COUNT(*) AS orders_count
FROM orders
GROUP BY customer_id;
«Какая выручка по каждому месяцу?» — нужен GROUP BY по месяцу.
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(amount) AS total_amount
FROM orders
GROUP BY 1
ORDER BY 1;
«Какая средняя цена товара в каждой категории?» — нужен GROUP BY category.
SELECT
category,
AVG(price) AS avg_price
FROM products
GROUP BY category;
Главное из статьи
GROUP BY — это способ собрать строки в группы и посчитать итог по каждой группе.
Было много строк:
| customer_id |
amount |
| 101 |
500 |
| 101 |
700 |
| 102 |
300 |
Стало по одной строке на группу:
| customer_id |
total_amount |
| 101 |
1200 |
| 102 |
300 |
Главные правила:
GROUP BY column группирует строки по одинаковым значениям в колонке.
GROUP BY a, b группирует по комбинации значений.
- В
SELECT можно указывать только колонки из GROUP BY и агрегатные функции.
COUNT(*) считает все строки.
COUNT(column) считает только строки, где column не NULL.
WHERE фильтрует строки до группировки.
HAVING фильтрует группы после группировки.
DISTINCT нужен для списка уникальных значений.
GROUP BY нужен для расчётов по группам.
- Все
NULL в группируемой колонке попадают в одну группу.
- Для группировки по месяцам часто удобнее использовать
DATE_TRUNC.
Если сказать совсем просто: GROUP BY — это инструмент для отчётов. Он берёт большую таблицу с отдельными событиями и превращает её в понятную сводку: по клиентам, странам, категориям, месяцам и любым другим группам.
GROUP BY— это команда, которая превращает много обычных строк в аккуратную сводку по группам.Без
GROUP BYтаблица отвечает на вопрос: «Какие строки у нас есть?»С
GROUP BYтаблица отвечает на другой вопрос: «Какие итоги получаются по каждой группе?»Например:
Проще говоря,
GROUP BYнужен, когда ты хочешь не просто посмотреть данные, а посчитать статистику.Простая идея GROUP BY
Представь таблицу заказов. В ней каждая строка — отдельный заказ:
Если сделать обычный
SELECT, ты увидишь все заказы по отдельности.Но если написать:
SELECT customer_id, COUNT(*) AS orders_count FROM orders GROUP BY customer_id;то база сгруппирует строки по
customer_id.Результат:
Пять строк превратились в три строки: по одной строке на каждого клиента.
Вот это и есть смысл
GROUP BY: собрать одинаковые значения в группы и посчитать что-то внутри каждой группы.Зачем нужен GROUP BY
GROUP BYпочти всегда идёт рядом с агрегатными функциями.Агрегатная функция — это функция, которая берёт несколько строк и возвращает одно итоговое значение.
Самые частые агрегатные функции:
COUNT(*)SUM(column)AVG(column)MIN(column)MAX(column)Например, если у нас есть таблица товаров, можно посчитать статистику по каждой категории:
SELECT category, COUNT(*) AS products_count, SUM(price) AS total_price, AVG(price) AS avg_price, MIN(price) AS min_price, MAX(price) AS max_price FROM products GROUP BY category;Такой запрос говорит базе:
Результат может быть таким:
Вместо списка отдельных товаров мы получили отчёт.
Базовый синтаксис GROUP BY
Самый простой вариант выглядит так:
SELECT country, COUNT(*) AS users_count FROM users GROUP BY country;Разберём по шагам.
База берёт таблицу
users, находит все строки, смотрит на значение в колонкеcountryи собирает одинаковые страны вместе.Если в таблице есть 100 пользователей из
RU, 50 пользователей изUSи 30 пользователей изDE, то получится три группы.Потом
COUNT(*)считает количество строк внутри каждой группы.На выходе будет одна строка на каждую страну.
Пример с таблицей users
Есть таблица
users:Запрос:
SELECT country, COUNT(*) AS users_count FROM users GROUP BY country;Результат:
Что произошло:
RUпопали в одну группу;USпопали в другую группу;COUNT(*)посчитал строки внутри каждой группы.То есть
GROUP BY countryне показывает каждого пользователя отдельно. Он показывает итог по каждой стране.GROUP BY по нескольким колонкам
Группировать можно не только по одной колонке.
Например, у пользователей есть страна и тариф:
Можно посчитать пользователей по каждой паре
country+tier:SELECT country, tier, COUNT(*) AS users_count FROM users GROUP BY country, tier ORDER BY country, tier;Результат:
Здесь группа — это не просто страна. Группа — это комбинация значений.
То есть:
RU+free— одна группа;RU+paid— другая группа;US+free— третья группа;US+paid— четвёртая группа.Простое правило:
Главное правило SELECT при GROUP BY
Это самый важный момент.
Когда в запросе есть
GROUP BY, вSELECTможно указывать только:GROUP BY;Например, так можно:
SELECT country, COUNT(*) AS users_count FROM users GROUP BY country;Почему можно?
Потому что
countryесть вGROUP BY, аCOUNT(*)— агрегатная функция.А вот так нельзя:
SELECT country, name, COUNT(*) AS users_count FROM users GROUP BY country;Почему нельзя?
Потому что в группе
RUможет быть несколько пользователей:Anna,Vera,Gleb.Какое имя должна показать база в колонке
name?Одно? Какое именно? Первое? Последнее? Случайное?
SQL не хочет угадывать. Поэтому и появляется ошибка: колонка должна либо участвовать в группировке, либо быть внутри агрегатной функции.
Правильно можно сделать так:
SELECT country, name, COUNT(*) AS rows_count FROM users GROUP BY country, name;Но теперь группировка изменилась. Группа — это уже не страна, а пара
country+name.Или можно так:
SELECT country, MAX(name) AS any_name, COUNT(*) AS users_count FROM users GROUP BY country;Но здесь
MAX(name)не означает «главное имя». Для текста это просто одно из значений по правилу сортировки. Использовать такой приём стоит только тогда, когда ты понимаешь, зачем тебе это нужно.Почему нельзя просто вывести любую колонку из группы
Представим группу по стране
RU:Если запрос такой:
SELECT country, name, COUNT(*) AS users_count FROM users GROUP BY country;то результат должен быть одной строкой на страну:
С
countryвсё понятно: вся группа имеет значениеRU.С
COUNT(*)тоже всё понятно: в группе 3 строки.А вот с
nameнепонятно. В группе три разных имени. Поэтому нормальная база данных не даст выполнить такой запрос.Это не придирка SQL, а защита от странного отчёта.
GROUP BY и порядок выполнения запроса
Новичкам часто кажется, что SQL выполняется сверху вниз: сначала
SELECT, потомFROM, потомGROUP BY.На самом деле логический порядок другой.
Упрощённо запрос выполняется так:
FROM— откуда берём строки.WHERE— какие строки оставляем до группировки.GROUP BY— как собираем строки в группы.HAVING— какие группы оставляем после группировки.SELECT— что выводим в результате.ORDER BY— как сортируем результат.LIMIT— сколько строк оставляем.Например:
SELECT country, COUNT(*) AS users_count FROM users WHERE registered_at >= DATE '2026-01-01' GROUP BY country HAVING COUNT(*) >= 10 ORDER BY users_count DESC LIMIT 5;Что здесь происходит:
users;2026-01-01;Это уже полноценный маленький отчёт.
WHERE и HAVING: в чём разница
WHEREиHAVINGоба фильтруют данные, но делают это в разные моменты.WHEREфильтрует строки до группировки.HAVINGфильтрует группы после группировки.Например, нужно посчитать пользователей по странам, но брать только активных пользователей.
SELECT country, COUNT(*) AS active_users_count FROM users WHERE status = 'active' GROUP BY country;Здесь
WHERE status = 'active'сначала убрал неактивных пользователей, и только потом база сгруппировала оставшиеся строки по странам.А теперь другая задача: показать только те страны, где активных пользователей больше 100.
SELECT country, COUNT(*) AS active_users_count FROM users WHERE status = 'active' GROUP BY country HAVING COUNT(*) > 100;Здесь:
WHEREоставляет только активных пользователей;GROUP BYсобирает их по странам;HAVINGоставляет только те группы, гдеCOUNT(*) > 100.Плохой вариант:
SELECT country, COUNT(*) AS users_count FROM users WHERE COUNT(*) > 100 GROUP BY country;Так нельзя, потому что на этапе
WHEREгрупп ещё нет. Значит,COUNT(*)ещё не посчитан.Запомнить можно так:
GROUP BY и ORDER BY
После группировки результат можно отсортировать.
Например, найти топ-5 стран по количеству пользователей:
SELECT country, COUNT(*) AS users_count FROM users GROUP BY country ORDER BY users_count DESC LIMIT 5;Здесь
ORDER BY users_count DESCсортирует уже готовые группы: сначала страны с большим количеством пользователей, потом с меньшим.Можно сортировать и прямо по агрегатной функции:
SELECT country, COUNT(*) AS users_count FROM users GROUP BY country ORDER BY COUNT(*) DESC;Но чаще удобнее дать агрегату понятное имя через
AS, а потом сортировать по этому имени.GROUP BY и DISTINCT
GROUP BYиногда путают сDISTINCT, потому что оба могут убрать повторяющиеся значения.Но задачи у них разные.
DISTINCTнужен, когда ты хочешь получить список уникальных значений.SELECT DISTINCT country FROM users;Результат:
А
GROUP BYнужен, когда ты хочешь не просто уникальные значения, а ещё и расчёты по каждой группе.SELECT country, COUNT(*) AS users_count FROM users GROUP BY country;Результат:
Простая разница:
DISTINCTотвечает: «Какие разные значения есть?»GROUP BYотвечает: «Какие группы есть и какие итоги у каждой группы?»Если нужен только список стран — бери
DISTINCT.Если нужно количество пользователей по странам — бери
GROUP BY.COUNT(*) и COUNT(column)
Это важная тема, потому что новички часто думают, что
COUNT(*)иCOUNT(email)делают одно и то же.На самом деле нет.
COUNT(*)считает все строки в группе.COUNT(column)считает только строки, где в этой колонке неNULL.Пример:
Запрос:
SELECT country, COUNT(*) AS rows_count, COUNT(email) AS emails_count FROM users GROUP BY country;Результат:
Для
RUвсего 3 строки, но email есть только у 2 пользователей.Поэтому:
COUNT(*);COUNT(column).GROUP BY и NULL
NULLвGROUP BYведёт себя особым образом: все строки сNULLв группируемой колонке попадают в одну группу.Например:
Запрос:
SELECT country, COUNT(*) AS users_count FROM users GROUP BY country ORDER BY country;Результат может быть таким:
То есть
NULLне исчезает сам по себе. Он образует отдельную группу.Если не хочешь видеть пользователей без страны, добавь фильтр до группировки:
SELECT country, COUNT(*) AS users_count FROM users WHERE country IS NOT NULL GROUP BY country ORDER BY country;GROUP BY по датам
С датами нужно быть аккуратным.
Если сгруппировать по полной дате и времени, можно получить слишком много групп.
Например:
SELECT created_at, COUNT(*) AS orders_count FROM orders GROUP BY created_at;Если
created_atсодержит точное время до секунд, почти каждый заказ может оказаться в отдельной группе.Для отчётов чаще группируют не по полной дате, а по периоду: день, месяц, год.
Например, заказы по дням:
SELECT DATE_TRUNC('day', created_at) AS day, COUNT(*) AS orders_count FROM orders GROUP BY 1 ORDER BY 1;Заказы по месяцам:
SELECT DATE_TRUNC('month', created_at) AS month, COUNT(*) AS orders_count FROM orders GROUP BY 1 ORDER BY 1;DATE_TRUNC('month', created_at)обрезает дату до начала месяца.Например:
Так мартовские даты попадут в одну группу, апрельские — в другую.
GROUP BY по месяцу: частая ловушка
Иногда хочется написать так:
SELECT EXTRACT(MONTH FROM created_at) AS month, COUNT(*) AS orders_count FROM orders GROUP BY 1 ORDER BY 1;Такой запрос сгруппирует заказы по номеру месяца:
1,2,3и так далее.Но есть проблема: январь 2025 года и январь 2026 года попадут в одну группу.
Если данные только за один год — всё нормально. Если данных несколько лет — отчёт станет неправильным.
Лучше добавить год:
SELECT EXTRACT(YEAR FROM created_at) AS year, EXTRACT(MONTH FROM created_at) AS month, COUNT(*) AS orders_count FROM orders GROUP BY 1, 2 ORDER BY 1, 2;Или использовать
DATE_TRUNC:SELECT DATE_TRUNC('month', created_at) AS month, COUNT(*) AS orders_count FROM orders GROUP BY 1 ORDER BY 1;Для отчётов по времени
DATE_TRUNCчасто удобнее и безопаснее.GROUP BY и SUM
Один из самых частых сценариев — сумма по группам.
Например, нужно посчитать выручку по каждому клиенту:
SELECT customer_id, SUM(amount) AS total_amount FROM orders GROUP BY customer_id ORDER BY total_amount DESC;Результат:
Так можно быстро понять, какие клиенты принесли больше всего денег.
GROUP BY и AVG
AVGсчитает среднее значение внутри группы.Например, средний чек по каждому клиенту:
SELECT customer_id, AVG(amount) AS avg_order_amount FROM orders GROUP BY customer_id ORDER BY avg_order_amount DESC;Если у клиента было три заказа на
500,700и100, средний чек будет:SELECT (500 + 700 + 100) / 3.0 AS avg_amount;Результат:
В реальных отчётах среднее часто округляют:
SELECT customer_id, ROUND(AVG(amount), 2) AS avg_order_amount FROM orders GROUP BY customer_id ORDER BY avg_order_amount DESC;GROUP BY и MIN/MAX
MINиMAXпомогают найти минимальное и максимальное значение внутри каждой группы.Например, первый и последний заказ клиента:
SELECT customer_id, MIN(created_at) AS first_order_at, MAX(created_at) AS last_order_at FROM orders GROUP BY customer_id;Результат:
Такой запрос часто используют, чтобы понять:
Полезный пример: отчёт по заказам клиентов
Допустим, есть таблица
orders:Можно собрать нормальный отчёт по каждому клиенту:
SELECT customer_id, COUNT(*) AS orders_count, SUM(amount) AS total_amount, ROUND(AVG(amount), 2) AS avg_amount, MIN(created_at) AS first_order_at, MAX(created_at) AS last_order_at FROM orders GROUP BY customer_id ORDER BY total_amount DESC;Результат:
Вот в этом месте
GROUP BYначинает ощущаться не как странная SQL-команда, а как инструмент аналитики.У тебя была таблица отдельных заказов. А получился отчёт по клиентам.
Частые ошибки новичков
Ошибка 1. Добавить в SELECT колонку, которой нет в GROUP BY
Плохой вариант:
SELECT country, name, COUNT(*) AS users_count FROM users GROUP BY country;Проблема: в одной стране может быть много имён. База не знает, какое имя показать.
Правильный вариант:
SELECT country, COUNT(*) AS users_count FROM users GROUP BY country;Или, если нужна группировка ещё и по имени:
SELECT country, name, COUNT(*) AS users_count FROM users GROUP BY country, name;Ошибка 2. Фильтровать агрегаты через WHERE
Плохой вариант:
SELECT country, COUNT(*) AS users_count FROM users WHERE COUNT(*) > 100 GROUP BY country;Так нельзя, потому что
WHEREработает до группировки.Правильный вариант:
SELECT country, COUNT(*) AS users_count FROM users GROUP BY country HAVING COUNT(*) > 100;HAVINGработает после группировки и умеет фильтровать по агрегатам.Ошибка 3. Путать COUNT(*) и COUNT(column)
SELECT country, COUNT(*) AS rows_count, COUNT(email) AS emails_count FROM users GROUP BY country;COUNT(*)считает все строки.COUNT(email)считает только строки, гдеemailнеNULL.Если в колонке много пустых значений, результаты будут сильно отличаться.
Ошибка 4. Группировать по слишком точному значению
Например:
SELECT created_at, COUNT(*) AS orders_count FROM orders GROUP BY created_at;Если в
created_atесть часы, минуты и секунды, групп получится слишком много.Для отчёта по месяцам лучше так:
SELECT DATE_TRUNC('month', created_at) AS month, COUNT(*) AS orders_count FROM orders GROUP BY 1 ORDER BY 1;Ошибка 5. Забыть про NULL
Если группировать по колонке, где есть
NULL, всеNULLпопадут в одну отдельную группу.Если такие строки не нужны, фильтруй их заранее:
SELECT country, COUNT(*) AS users_count FROM users WHERE country IS NOT NULL GROUP BY country;Ошибка 6. Группировать по месяцу без года
Плохой вариант для данных за несколько лет:
SELECT EXTRACT(MONTH FROM created_at) AS month, SUM(amount) AS total_amount FROM orders GROUP BY 1 ORDER BY 1;Так январь разных лет смешается.
Лучше:
SELECT EXTRACT(YEAR FROM created_at) AS year, EXTRACT(MONTH FROM created_at) AS month, SUM(amount) AS total_amount FROM orders GROUP BY 1, 2 ORDER BY 1, 2;Или:
SELECT DATE_TRUNC('month', created_at) AS month, SUM(amount) AS total_amount FROM orders GROUP BY 1 ORDER BY 1;GROUP BY в PostgreSQL и MySQL
В PostgreSQL правило строгое: если колонка стоит в
SELECT, она должна быть либо вGROUP BY, либо внутри агрегатной функции.Например, такой запрос PostgreSQL не пропустит:
SELECT country, name, COUNT(*) AS users_count FROM users GROUP BY country;И это хорошо: база защищает тебя от случайного и непонятного результата.
В MySQL раньше можно было встретить более мягкое поведение: база могла вернуть какое-то значение из группы. Но в строгом режиме с
ONLY_FULL_GROUP_BYMySQL тоже требует нормальной логики группировки.Практический совет простой:
Как читать запрос с GROUP BY
Возьмём запрос:
SELECT category, COUNT(*) AS products_count, AVG(price) AS avg_price FROM products WHERE is_active = true GROUP BY category HAVING COUNT(*) >= 5 ORDER BY avg_price DESC;Читать его удобно не сверху вниз, а по смыслу:
products.Такой способ чтения помогает не путаться, особенно когда запрос становится длиннее.
Когда использовать GROUP BY
Используй
GROUP BY, когда в задаче звучат слова:Например:
«Сколько заказов у каждого клиента?» — почти точно нужен
GROUP BY customer_id.SELECT customer_id, COUNT(*) AS orders_count FROM orders GROUP BY customer_id;«Какая выручка по каждому месяцу?» — нужен
GROUP BYпо месяцу.SELECT DATE_TRUNC('month', created_at) AS month, SUM(amount) AS total_amount FROM orders GROUP BY 1 ORDER BY 1;«Какая средняя цена товара в каждой категории?» — нужен
GROUP BY category.SELECT category, AVG(price) AS avg_price FROM products GROUP BY category;Главное из статьи
GROUP BY— это способ собрать строки в группы и посчитать итог по каждой группе.Было много строк:
Стало по одной строке на группу:
Главные правила:
GROUP BY columnгруппирует строки по одинаковым значениям в колонке.GROUP BY a, bгруппирует по комбинации значений.SELECTможно указывать только колонки изGROUP BYи агрегатные функции.COUNT(*)считает все строки.COUNT(column)считает только строки, гдеcolumnнеNULL.WHEREфильтрует строки до группировки.HAVINGфильтрует группы после группировки.DISTINCTнужен для списка уникальных значений.GROUP BYнужен для расчётов по группам.NULLв группируемой колонке попадают в одну группу.DATE_TRUNC.Если сказать совсем просто:
GROUP BY— это инструмент для отчётов. Он берёт большую таблицу с отдельными событиями и превращает её в понятную сводку: по клиентам, странам, категориям, месяцам и любым другим группам.