HAVING в SQL: фильтр после GROUP BY и отличие от WHERE
HAVING в SQL: фильтр после GROUP BY, отличие от WHERE, порядок выполнения запроса, HAVING COUNT и алиасы в PostgreSQL и DuckDB.
Содержание статьи
Продакт просит список каналов, которые привели хотя бы тысячу пользователей: остальные он не хочет обсуждать на планёрке. Вы пишете where count(*) >= 1000, и PostgreSQL отвечает ошибкой «aggregate functions are not allowed in WHERE». Условие правильное, место неправильное: на шаге WHERE групп ещё нет, и считать тысячу не из чего. Для фильтра по результату группировки в SQL есть отдельное слово — HAVING.
Коротко
Если нужно одно правило: условие про отдельную строку пишите в WHERE, условие про группу целиком — в HAVING.
- HAVING фильтрует группы после GROUP BY, поэтому в нём можно сравнивать
count(*),sum,avgи другие агрегаты. - WHERE выполняется раньше группировки и видит только исходные строки: агрегат в нём даёт ошибку.
- Алиас из SELECT в HAVING работает в DuckDB, но не в PostgreSQL: там выражение повторяют целиком или выносят расчёт в CTE.
- Условие по колонке из GROUP BY можно написать и в WHERE, и в HAVING. PostgreSQL сам переносит его в WHERE, но читателю запроса удобнее видеть его там сразу.
- HAVING без GROUP BY превращает весь набор в одну группу и может вернуть ноль строк.
Что такое HAVING в SQL
HAVING — это часть запроса, которая отбирает группы, уже собранные через GROUP BY. Условие в HAVING проверяется один раз для каждой группы, и в нём можно использовать агрегатные функции: count, sum, avg, min, max. Группа, для которой условие не истинно, в результат не попадает.
Синтаксически HAVING стоит сразу после GROUP BY и до ORDER BY. В условии можно ссылаться на агрегаты и на колонки, по которым идёт группировка. На любую другую колонку ссылаться нельзя: у группы из тысячи строк нет одного значения country или signup_date, если по ним не группировали.
select channel, count(*) as users
from users
group by channel
having count(*) >= 1000
order by users desc;
-- organic 1747, referral 1037, paid_search 1015
-- partner (814) отброшен на шаге HAVINGВ каком порядке выполняется запрос с HAVING
Запрос пишется в одном порядке, а логически выполняется в другом. Почти все вопросы про HAVING — почему агрегат нельзя в WHERE, почему не виден алиас, почему условие по колонке не срабатывает — снимаются, если держать перед глазами эту последовательность.
Реальный план выполнения база строит сама и может переставлять шаги, если результат от этого не меняется. Но видимость имён и то, какие условия где разрешены, определяются именно логическим порядком.
| Шаг | Клауза | Что происходит | Что уже доступно |
|---|---|---|---|
| 1 | FROM, JOIN | собираются исходные строки | колонки таблиц |
| 2 | WHERE | отбрасываются отдельные строки | колонки таблиц, без агрегатов |
| 3 | GROUP BY | строки склеиваются в группы | ключи группировки |
| 4 | HAVING | отбрасываются целые группы | ключи группировки и агрегаты |
| 5 | SELECT | вычисляются выражения и алиасы | всё выше плюс алиасы |
| 6 | ORDER BY | сортируется результат | алиасы из SELECT |
| 7 | LIMIT, OFFSET | обрезается число строк | готовый отсортированный результат |
Чем HAVING отличается от WHERE
Разница не в синтаксисе, а в том, что именно фильтруется. WHERE решает судьбу каждой строки до группировки и тем самым меняет то, что потом посчитают агрегаты. HAVING получает уже посчитанные группы и только решает, показывать ли их.
| WHERE | HAVING | |
|---|---|---|
| Когда выполняется | до GROUP BY | после GROUP BY |
| Что фильтрует | строки таблицы | группы |
| Агрегаты в условии | нельзя: ошибка | можно |
| Колонки не из GROUP BY | можно | нельзя: ошибка |
| Влияет на значения агрегатов | да, меняет состав групп | нет, только отбирает готовые |
| Работает без GROUP BY | да | да, весь набор — одна группа |
HAVING COUNT: страны, каналы и пустая группа
Самое частое применение — порог по числу строк в группе. С каналами пример уже был, со странами есть нюанс. У 55 пользователей страна не заполнена, и GROUP BY собирает их в отдельную группу с ключом NULL. HAVING не отличает её от остальных: having count(*) >= 50 её пропустит, и в отчёте появится строка с пустой страной.
Это правильное поведение, но о нём легко забыть. Если строка без страны в отчёте не нужна, уберите такие записи в WHERE через country is not null — это условие про строку, а не про группу. Если нужна, подпишите её через coalesce(country, 'не указана'), чтобы пустая ячейка не выглядела ошибкой выгрузки.
| country | users |
|---|---|
| RU | 2715 |
| KZ | 912 |
| AM | 517 |
| BY | 414 |
| NULL | 55 |
Пользователи с тремя и более платежами
Когда группировка идёт по пользователю, HAVING отвечает на вопросы вида «кто сделал что-то N раз». В учебной базе 622 человека заплатили один раз, 241 — два раза, 49 — три. Порог >= 3 оставляет 49 пользователей, порог >= 2 — 290.
Если нужно не само число, а список, уберите внешний count(*) и добавьте сортировку. Только у сортировки по выручке здесь много ничьих: у пятерых первых одинаково 3 платежа и 117 выручки. Без второго ключа в ORDER BY порядок таких строк не гарантирован, об этом подробнее в статье про ORDER BY и LIMIT.
select count(*) as users_3plus
from (
select user_id
from payments
group by user_id
having count(*) >= 3
) as t;Дни, где конверсия ниже порога
Практический случай: найти дни эксперимента, где конверсия подозрительно низкая. Средняя конверсия по всем 3068 показам — 25,2%. Ищем дни ниже 15%.
Первая версия запроса с одним условием на долю возвращает 7 дней. Два из них — 5 июля с 18 показами и 7 июня с семью. На таких объёмах один пользователь сдвигает долю на 6–14 процентных пунктов, и «аномалия» ничего не значит. Второе условие на размер группы оставляет 5 дней, в которых было хотя бы 20 показов.
Обратите внимание: count(*) filter (where converted) и count(*) — два разных агрегата над одной группой, и HAVING сравнивает их отношение. Это тоже агрегатное условие, поэтому оно может жить только здесь, а не в WHERE.
| day | exposures | cr_pct |
|---|---|---|
| 2026-06-10 | 20 | 5,0 |
| 2026-06-15 | 27 | 11,1 |
| 2026-06-12 | 30 | 13,3 |
| 2026-07-25 | 22 | 13,6 |
| 2026-07-17 | 42 | 14,3 |
select
cast(exposed_at as date) as day,
count(*) as exposures,
round(100.0 * count(*) filter (where converted) / count(*), 1) as cr_pct
from experiment_exposures
group by cast(exposed_at as date)
having count(*) >= 20
and 100.0 * count(*) filter (where converted) / count(*) < 15
order by cr_pct, day;HAVING с несколькими агрегатами
Условия в HAVING соединяются через AND и OR так же, как в WHERE, и каждое может использовать свой агрегат. Например, среди крупных каналов (от 900 пользователей) найти те, где доля мобильных выше 48%. Под оба условия подходит только paid_search: 1015 пользователей, 66,0% с мобильных. У organic 45,4%, у referral 42,1%, а partner с 814 пользователями не проходит по размеру.
Внутри одного HAVING можно смешивать условие на агрегат и условие на ключ группировки. PostgreSQL в этом случае делит его на две части: channel <> 'organic' уходит в фильтр при чтении таблицы, а count(*) >= 1000 остаётся на шаге агрегации. Это видно в EXPLAIN.
select
channel,
count(*) as users,
round(100.0 * count(*) filter (where device = 'mobile') / count(*), 1) as mobile_pct
from users
group by channel
having count(*) >= 900
and 100.0 * count(*) filter (where device = 'mobile') / count(*) > 48;
-- paid_search | 1015 | 66.0Можно ли использовать алиас в HAVING
Зависит от базы. DuckDB, на котором работает песочница курса, разрешает сослаться на алиас из SELECT: having user_cnt >= 1000 выполнится и вернёт три канала. PostgreSQL следует логическому порядку строго: HAVING идёт раньше SELECT, алиаса на этом шаге ещё нет.
Отдельный сюрприз — алиас, совпадающий с именем таблицы. Если назвать счётчик users, как таблицу, PostgreSQL решит, что users в HAVING — это ссылка на всю строку таблицы, и выдаст ошибку про оператор сравнения, которая совсем не похожа на «алиас не найден». DuckDB в этом случае выбирает алиас и выполняет запрос.
Переносимых решений два. Повторить выражение в HAVING, как в примерах выше. Или посчитать агрегаты в CTE и отфильтровать внешний запрос обычным WHERE — тогда exposures и cr_pct становятся колонками, и длинные формулы не дублируются. Второй способ удобнее, когда условий больше одного.
select channel, count(*) as user_cnt
from users group by channel
having user_cnt >= 1000;
-- ERROR: column "user_cnt" does not exist
-- HINT: Perhaps you meant to reference the column "users.user_id".
select channel, count(*) as users
from users group by channel
having users >= 1000;
-- ERROR: operator does not exist: users >= integerОдно и то же условие в WHERE и в HAVING
Условие по колонке, которая есть в GROUP BY, можно написать в обоих местах: where channel <> 'partner' и having channel <> 'partner' возвращают одни и те же три канала. Часто пишут, что HAVING здесь медленнее, потому что база сначала построит лишнюю группу. PostgreSQL 14 на учебной базе с этим не согласен: EXPLAIN для обоих вариантов одинаковый, и фильтр стоит на чтении таблицы, до агрегации. Планировщик переносит в WHERE условия HAVING, в которых нет агрегатов.
Причина держать такой фильтр в WHERE — не скорость, а смысл. WHERE — место, где читатель ищет границы выборки: период, сегмент, исключения. Условие, спрятанное среди порогов по выручке, легко не заметить.
Когда условие касается не ключа группировки, WHERE и HAVING отвечают на разные вопросы. «У кого два и больше платежей в августе» — это WHERE по дате и HAVING по количеству: 0 пользователей, потому что продления идут раз в месяц. «У кого два и больше платежей, и последний из них в августе» — это только HAVING с max(paid_at): 232 пользователя. Оба запроса синтаксически верны, а ответы разные.
-- платежи в августе отобраны до подсчёта
select count(*) from (
select user_id from payments
where paid_at >= date '2026-08-01'
group by user_id
having count(*) >= 2
) as t; -- 0
-- считаем все платежи, дату проверяем у группы
select count(*) from (
select user_id from payments
group by user_id
having count(*) >= 2
and max(paid_at) >= date '2026-08-01'
) as t; -- 232HAVING без GROUP BY
Если GROUP BY нет, весь результат после WHERE считается одной группой. HAVING при этом решает, вернуть ли единственную итоговую строку или ничего. select count(*), sum(amount) from payments having count(*) > 1000 вернёт одну строку: 1251 платёж на 30 639. С порогом > 2000 запрос вернёт ноль строк, а не строку с нулём.
Эта особенность полезна для проверок качества данных: запрос возвращает строку только тогда, когда что-то не так, и его легко повесить на алерт. Но в обычном отчёте пустой результат вместо итога сбивает с толку, поэтому там лучше вернуть число и сравнить его с порогом в CASE.
Запрос select user_id, paid_at, count(*) from payments group by user_id, paid_at having count(*) > 1 ищет пользователей с двумя платежами в один день. На учебной базе он возвращает пустой результат, и это ответ: таких дублей нет.
Частые ошибки с HAVING
Большая часть ошибок видна сразу по тексту сообщения. Хуже те, что выполняются без ошибки и меняют смысл отчёта.
- Агрегат в WHERE. PostgreSQL: «aggregate functions are not allowed in WHERE», DuckDB: «WHERE clause cannot contain aggregates!». Перенесите условие в HAVING.
- Колонка не из GROUP BY в HAVING.
having country = 'RU'при группировке по каналу даёт «column "users.country" must appear in the GROUP BY clause or be used in an aggregate function». Если нужен фильтр по стране, это WHERE. - Алиас в HAVING, который работал в песочнице, но упал в PostgreSQL. Повторите выражение или вынесите расчёт в CTE.
- Порог по доле без порога по объёму. Дни с семью показами попадают в «аномалии» случайно.
- HAVING после JOIN один-ко-многим.
having count(*) >= 3после соединения с событиями считает события, а не платежи. Проверьте, что одна строка до группировки — это то, что вы считаете. - Забытая группа NULL. Она проходит пороги наравне с остальными и появляется в отчёте пустой строкой.
Чеклист перед тем, как отдать запрос с HAVING
- Каждое условие про отдельную строку стоит в WHERE, про группу — в HAVING.
- Понятно, что такое одна строка до GROUP BY, и агрегат считает именно её.
- Для долей и средних есть минимальный размер группы.
- Запрос не опирается на алиас в HAVING, если его будут запускать в PostgreSQL.
- Группа с NULL в ключе либо убрана в WHERE, либо подписана.
- Результат отсортирован с уникальным последним ключом, если его будут сравнивать с прошлой выгрузкой.
Проверьте на учебной базе
В песочнице SQL-курса выполните запрос по дням эксперимента без условия на объём и с ним, затем замените порог 20 на 30 и посмотрите, какие дни уйдут. Потом перепишите тот же отбор через CTE с WHERE и убедитесь, что ответ не изменился. Если хотите проверить поведение PostgreSQL с алиасом, запустите тот же запрос на своей локальной базе: песочница работает на DuckDB и алиас простит.
Материалы по теме

PARTITION BY и OVER в SQL: окно, группы и отличие от GROUP BY
PARTITION BY и OVER в SQL: окно и отличие от GROUP BY, доля от итога, накопительный итог, рамки ROWS и RANGE, фильтр по оконной функции.

DISTINCT в SQL: уникальные строки, COUNT(DISTINCT) и DISTINCT ON
Как работает DISTINCT в SQL на реальной учебной базе: уникальность по всей строке, DISTINCT по нескольким столбцам, COUNT(DISTINCT) для DAU, NULL, отличие от GROUP BY, DISTINCT ON в PostgreSQL и DuckDB, и почему DISTINCT после JOIN прячет ошибку в выручке.
Ошибки в SQL-запросах: тексты сообщений, причины и исправления
Частые ошибки SQL с дословными текстами PostgreSQL и DuckDB: GROUP BY, column does not exist, ambiguous, division by zero, типы и даты, и пять запросов, которые молча врут.