WHERE фільтрує рядки до групування й не бачить агрегатів, GROUP BY згортає рядки в групи, HAVING фільтрує вже готові групи за значенням агрегату.
Щоб зрозуміти різницю, треба тримати в голові логічний порядок виконання запиту: FROM і JOIN збирають рядки, WHERE фільтрує їх по одному, GROUP BY згортає рядки в групи за однаковими значеннями ключа групування, всередині груп обчислюються агрегати (COUNT, SUM, AVG, MIN, MAX), HAVING відкидає непотрібні групи, і тільки потім працюють SELECT, ORDER BY та LIMIT. Це не порядок написання, а порядок сенсу — фізично планувальник може робити по-своєму, але результат зобовʼязаний збігатися з цією моделлю.
Звідси головна відповідь: WHERE не бачить COUNT(*), бо на момент його роботи груп ще не існує — є лише окремі рядки, і запит WHERE COUNT(*) >= 3 падає з помилкою (Invalid use of group function у MySQL, aggregate functions are not allowed in WHERE у PostgreSQL). HAVING, навпаки, виконується після агрегації, тому оперує вже порахованими значеннями. Симетрично HAVING майже не має сенсу для звичайної колонки: після групування окремих рядків уже немає, є ключ групування й агрегати.
Другий бік — продуктивність. Технічно можна винести умову на звичайну колонку в HAVING (MySQL це проковтне, бо дозволяє в HAVING посилатися на колонки й аліаси SELECT; PostgreSQL стандартно вимагатиме агрегат або колонку з GROUP BY). Але тоді база згрупує зайві рядки й лише потім їх викине, а індекс по цій колонці не спрацює для звуження пошуку. Правило просте: умова про значення рядка — у WHERE, умова про результат агрегації — у HAVING.
Після GROUP BY у SELECT дозволені тільки колонки з ключа групування та агрегати. MySQL до 5.7.5 із вимкненим ONLY_FULL_GROUP_BY мовчки повертав довільне значення з групи, і це давало нестабільні результати між запусками; з 5.7.5 режим увімкнений за замовчуванням, а PostgreSQL поводився так завжди. Виняток — функційна залежність: якщо групувати за первинним ключем (GROUP BY u.id), інші колонки цієї таблиці брати можна, бо вони визначені однозначно (PostgreSQL з 9.1, MySQL 8).
Дві деталі, на яких найчастіше помиляються джуни. Перша: COUNT(*) рахує рядки групи, а COUNT(column), SUM, AVG ігнорують NULL — тому COUNT(o.coupon) менший за COUNT(*), і це не баг. Друга: GROUP BY не гарантує сортування. У старих MySQL воно було побічним ефектом реалізації, у 8.0 неявне сортування й модифікатори ASC/DESC для GROUP BY прибрані, тож потрібний порядок задають явним ORDER BY.
-- Питання: користувачі, у яких від 3 оплачених замовлень за 2026 рік,
-- на суму понад 10000, найбільші зверху.
SELECT
u.id,
u.email,
COUNT(*) AS paid_orders, -- рядків у групі
SUM(o.total) AS revenue, -- SUM ігнорує NULL
COUNT(o.coupon) AS with_coupon -- рахує лише не-NULL купони
FROM users u
JOIN orders o ON o.user_id = u.id
-- WHERE працює до групування: відсікає рядки, може використати індекс
WHERE o.status = 'paid'
AND o.created_at >= '2026-01-01'
-- GROUP BY згортає рядки в групи; u.email можна брати,
-- бо u.id — первинний ключ (функційна залежність)
GROUP BY u.id, u.email
-- HAVING працює після агрегації: тут і тільки тут доступні COUNT/SUM
HAVING COUNT(*) >= 3 AND SUM(o.total) > 10000
-- GROUP BY не сортує сам: у MySQL 8.0 неявне сортування прибрано
ORDER BY revenue DESC
LIMIT 20;
-- Помилка: агрегата у WHENE ще не існує, груп немає
-- WHERE COUNT(*) >= 3 -- ERROR: Invalid use of group function / aggregate not allowed
-- Кілька лічильників за один прохід (PostgreSQL)
SELECT user_id,
COUNT(*) AS all_orders,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders
FROM orders
GROUP BY user_id;
Скажіть одним реченням: «WHERE — про рядки, HAVING — про групи», і одразу назвіть порядок виконання. Далі додайте, що умову на звичайну колонку завжди тримають у WHERE, бо це менше роботи для GROUP BY.