Все материалы
Продуктовая аналитикагайдстарт

HAVING в SQL: фильтр после GROUP BY и отличие от WHERE

HAVING в SQL: фильтр после GROUP BY, отличие от WHERE, порядок выполнения запроса, HAVING COUNT и алиасы в PostgreSQL и DuckDB.

КейсПрактика25 сентября 2026 г.11 мин

Продакт просит список каналов, которые привели хотя бы тысячу пользователей: остальные он не хочет обсуждать на планёрке. Вы пишете 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, если по ним не группировали.

Каналы, которые привели не меньше 1000 пользователей: organic, referral, paid_search
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, почему не виден алиас, почему условие по колонке не срабатывает — снимаются, если держать перед глазами эту последовательность.

Реальный план выполнения база строит сама и может переставлять шаги, если результат от этого не меняется. Но видимость имён и то, какие условия где разрешены, определяются именно логическим порядком.

Логический порядок выполнения SELECT
ШагКлаузаЧто происходитЧто уже доступно
1FROM, JOINсобираются исходные строкиколонки таблиц
2WHEREотбрасываются отдельные строкиколонки таблиц, без агрегатов
3GROUP BYстроки склеиваются в группыключи группировки
4HAVINGотбрасываются целые группыключи группировки и агрегаты
5SELECTвычисляются выражения и алиасывсё выше плюс алиасы
6ORDER BYсортируется результаталиасы из SELECT
7LIMIT, OFFSETобрезается число строкготовый отсортированный результат

Чем HAVING отличается от WHERE

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

WHERE и HAVING рядом
WHEREHAVING
Когда выполняетсядо 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, 'не указана'), чтобы пустая ячейка не выглядела ошибкой выгрузки.

select country, count(*) as users from users group by country having count(*) >= 50
countryusers
RU2715
KZ912
AM517
BY414
NULL55

Пользователи с тремя и более платежами

Когда группировка идёт по пользователю, HAVING отвечает на вопросы вида «кто сделал что-то N раз». В учебной базе 622 человека заплатили один раз, 241 — два раза, 49 — три. Порог >= 3 оставляет 49 пользователей, порог >= 2 — 290.

Если нужно не само число, а список, уберите внешний count(*) и добавьте сортировку. Только у сортировки по выручке здесь много ничьих: у пятерых первых одинаково 3 платежа и 117 выручки. Без второго ключа в ORDER BY порядок таких строк не гарантирован, об этом подробнее в статье про ORDER BY и LIMIT.

Сколько пользователей заплатили три раза и больше: 49
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.

Результат: 5 дней
dayexposurescr_pct
2026-06-10205,0
2026-06-152711,1
2026-06-123013,3
2026-07-252213,6
2026-07-174214,3
Дни с конверсией ниже 15% среди дней с 20+ показами
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.

Два агрегатных условия в одном HAVING
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 становятся колонками, и длинные формулы не дублируются. Второй способ удобнее, когда условий больше одного.

sqlОтветы PostgreSQL 14 на алиас в HAVING
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 пользователя. Оба запроса синтаксически верны, а ответы разные.

0 и 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;                       -- 232

HAVING без GROUP BY

Если GROUP BY нет, весь результат после WHERE считается одной группой. HAVING при этом решает, вернуть ли единственную итоговую строку или ничего. select count(*), sum(amount) from payments having count(*) > 1000 вернёт одну строку: 1251 платёж на 30 639. С порогом > 2000 запрос вернёт ноль строк, а не строку с нулём.

Эта особенность полезна для проверок качества данных: запрос возвращает строку только тогда, когда что-то не так, и его легко повесить на алерт. Но в обычном отчёте пустой результат вместо итога сбивает с толку, поэтому там лучше вернуть число и сравнить его с порогом в CASE.

Проверка на дубли — тот же HAVING

Запрос 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 и алиас простит.

Продолжить чтение
Вся библиотека