Собесов

DataLearn SQL-101: GROUP BY и HAVING — что куда писать

SQLAggregationЛёгкаяJunior

Условие

Дана таблица orders(order_id, user_id, dt, amount, status).

  1. Найдите клиентов, у которых больше 5 заказов в 2025 году.
  2. Из них оставьте тех, у кого средний чек > 1000.
  3. Покажите топ-10 по сумме заказов.

Объясните, чем WHERE отличается от HAVING, и где синтаксически разница.

Решение

Подход

WHERE фильтрует строки до агрегации. HAVINGгруппы после агрегации.

Порядок выполнения логически:

FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT

Запрос

SELECT
    user_id,
    COUNT(*)            AS n_orders,
    AVG(amount)         AS avg_check,
    SUM(amount)         AS total_amount
FROM orders
WHERE dt >= '2025-01-01' AND dt < '2026-01-01'    -- pre-agg фильтр
  AND status = 'paid'
GROUP BY user_id
HAVING COUNT(*) > 5                                -- post-agg фильтр
   AND AVG(amount) > 1000
ORDER BY total_amount DESC
LIMIT 10;

Альтернатива через CTE (читаемее для 3+ условий)

WITH agg AS (
    SELECT user_id,
           COUNT(*) AS n_orders,
           AVG(amount) AS avg_check,
           SUM(amount) AS total_amount
    FROM orders
    WHERE dt >= '2025-01-01' AND dt < '2026-01-01'
      AND status = 'paid'
    GROUP BY user_id
)
SELECT *
FROM agg
WHERE n_orders > 5 AND avg_check > 1000
ORDER BY total_amount DESC
LIMIT 10;

В CTE post-agg фильтр пишется через WHERE — потому что агрегация уже сделана внутри CTE.

WHERE vs HAVING на одном примере

-- считаем только paid:                  WHERE
-- среди user_id оставляем активных:     HAVING
-- если бы написать HAVING status='paid' — ошибка: status вне GROUP BY

Алиасы в HAVING

-- Postgres / Snowflake: ОК
SELECT user_id, COUNT(*) AS n
FROM orders GROUP BY user_id
HAVING n > 5;                  -- алиас работает в Postgres
 
-- MySQL до 8.0: ошибка, нужно повторить агрегат
HAVING COUNT(*) > 5;

COUNT(*) vs COUNT(col)

SELECT user_id,
       COUNT(*)           AS rows_total,        -- считает все строки
       COUNT(amount)      AS rows_with_amount,  -- не считает NULL
       COUNT(DISTINCT dt) AS days_active
FROM orders
GROUP BY user_id;

Подводные камни

  1. Фильтр по агрегату в WHERE → ошибка: WHERE COUNT(*) > 5 (агрегаты в WHERE запрещены).
  2. Колонка в SELECT, не в GROUP BY — ошибка в Postgres / стандарте; MySQL <8.0 молча даёт «произвольную строку».
  3. HAVING без GROUP BY работает: фильтрует одну агрегированную строку. SELECT COUNT(*) FROM orders HAVING COUNT(*) > 0.
  4. DISTINCT + GROUP BY — почти всегда избыточно: GROUP BY и так группирует.
  5. COUNT(DISTINCT col) дорого на больших таблицах. Использовать APPROX_COUNT_DISTINCT в BigQuery / HLL_COUNT_DISTINCT в Snowflake.
  6. Часовые пояса: dt >= '2025-01-01' зависит от типа dt (date / timestamp / timestamptz). Всегда явно dt::date >= '2025-01-01'.
  7. NULL в GROUP BY — собирается в одну группу (стандарт SQL). Если данных много с NULL — проверить отдельно.

Эталонный ответ

SELECT user_id, COUNT(*) AS n, AVG(amount) AS avg_check, SUM(amount) AS total
FROM orders
WHERE dt >= '2025-01-01' AND dt < '2026-01-01' AND status = 'paid'
GROUP BY user_id
HAVING COUNT(*) > 5 AND AVG(amount) > 1000
ORDER BY total DESC
LIMIT 10;

WHERE — фильтр строк до агрегации. HAVING — групп после. Алиасы агрегатов в HAVING работают в Postgres, но не в MySQL. CTE — частая альтернатива HAVING для читаемости.

Хочешь увидеть разбор?

Зарегистрируйся бесплатно — откроется развёрнутое решение этой задачи и ещё 4 на выбор.

Зарегистрироваться и увидеть разбор
Уже есть аккаунт? Войти