Задачи на агрегацию SQL на собеседовании

Карьерник — квиз-тренажёр в Telegram с 1500+ задач и вопросов для собесов аналитика. SQL, Python, A/B, метрики. Бесплатно.

Что проверяют

Агрегация — 50% любого собеседования аналитика. Проверяют:

  • GROUP BY с несколькими колонками
  • HAVING vs WHERE
  • Агрегаты с условиями (через CASE)
  • COUNT DISTINCT
  • Работу с NULL в агрегатах

Задачи с разборами

Задача 1. Количество пользователей по странам

users(user_id, country)

SELECT country, COUNT(*) AS users_count
FROM users
GROUP BY country
ORDER BY users_count DESC;

Ловушка: COUNT(*) считает все строки, включая NULL. COUNT(country) — пропускает NULL. На собесе спросят: знаете ли разницу?


Задача 2. Средний чек в выходные и будни

orders(created_at, total)

SELECT
    CASE
        WHEN EXTRACT(DOW FROM created_at) IN (0, 6) THEN 'weekend'
        ELSE 'weekday'
    END AS day_type,
    AVG(total) AS avg_check,
    COUNT(*) AS orders_cnt
FROM orders
GROUP BY 1;

Задача 3. Топ-5 категорий по выручке

orders(order_id, category, total)

SELECT category, SUM(total) AS revenue
FROM orders
GROUP BY category
ORDER BY revenue DESC
LIMIT 5;

Задача 4. Количество уникальных товаров на заказ

order_items(order_id, product_id, qty)

SELECT
    order_id,
    COUNT(DISTINCT product_id) AS unique_products,
    SUM(qty) AS total_items
FROM order_items
GROUP BY order_id;

Задача 5. Категории, где больше 100 заказов

SELECT category, COUNT(*) AS orders_count
FROM orders
GROUP BY category
HAVING COUNT(*) > 100;

Ключевое: HAVING — для фильтрации после агрегации. WHERE — до.


Задача 6. Доля заказов со скидкой

orders(order_id, has_discount BOOL)

SELECT
    COUNT(*) AS total_orders,
    SUM(CASE WHEN has_discount THEN 1 ELSE 0 END) AS discounted,
    AVG(CASE WHEN has_discount THEN 1.0 ELSE 0 END) AS discount_share
FROM orders;

Постгрес-specific: можно AVG(has_discount::int) или COUNT(*) FILTER (WHERE has_discount).


Задача 7. Средняя сумма для пользователей с 3+ заказами

SELECT
    user_id,
    AVG(total) AS avg_order,
    COUNT(*) AS orders_cnt
FROM orders
GROUP BY user_id
HAVING COUNT(*) >= 3;

Задача 8. Максимум и минимум в одном запросе

SELECT
    MAX(total) AS max_total,
    MIN(total) AS min_total,
    AVG(total) AS avg_total,
    MAX(total) - MIN(total) AS RANGE
FROM orders;

Задача 9. Выручка по месяцам

SELECT
    DATE_TRUNC('month', created_at) AS month,
    SUM(total) AS revenue,
    COUNT(*) AS orders,
    COUNT(DISTINCT user_id) AS buyers
FROM orders
GROUP BY 1
ORDER BY 1;

Задача 10. Топ-10 пользователей по числу заказов

SELECT user_id, COUNT(*) AS orders_count
FROM orders
GROUP BY user_id
ORDER BY orders_count DESC
LIMIT 10;

Задача 11. Категории без заказов в апреле

SELECT c.category
FROM categories c
LEFT JOIN orders o
    ON o.category = c.category
   AND o.created_at >= '2026-04-01'
   AND o.created_at < '2026-05-01'
GROUP BY c.category
HAVING COUNT(o.order_id) = 0;

Критично: условие даты идёт в ON, а не в WHERE, иначе LEFT JOIN превратится в INNER.


Задача 12. Средняя сумма второго заказа по когортам

Сложнее. orders + когорта по месяцу первого заказа.

WITH user_orders AS (
    SELECT
        user_id,
        total,
        ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS order_num,
        MIN(created_at) OVER (PARTITION BY user_id) AS first_order_at
    FROM orders
)
SELECT
    DATE_TRUNC('month', first_order_at) AS cohort_month,
    AVG(total) AS avg_second_order
FROM user_orders
WHERE order_num = 2
GROUP BY 1;

Задача 13. Доля возвратов по категориям

orders(category, total, status)

SELECT
    category,
    COUNT(*) AS total,
    SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) AS refunds,
    ROUND(100.0 * SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) / COUNT(*), 2) AS refund_pct
FROM orders
GROUP BY category
ORDER BY refund_pct DESC;

Задача 14. Количество активных дней пользователя

SELECT
    user_id,
    COUNT(DISTINCT DATE(event_at)) AS active_days
FROM events
GROUP BY user_id;

Задача 15. Пользователи с одинаковым количеством заказов

SELECT orders_count, COUNT(*) AS users_cnt
FROM (
    SELECT user_id, COUNT(*) AS orders_count
    FROM orders
    GROUP BY user_id
) t
GROUP BY orders_count
ORDER BY orders_count;

Частые ошибки

WHERE vs HAVING

-- неправильно — агрегат в WHERE
SELECT category, COUNT(*) FROM orders
WHERE COUNT(*) > 100  -- ошибка
GROUP BY category;

-- правильно
SELECT category, COUNT(*) FROM orders
GROUP BY category
HAVING COUNT(*) > 100;

Правило:

  • WHERE — до группировки (фильтрует строки)
  • HAVING — после (фильтрует группы)

COUNT(*) vs COUNT(column) vs COUNT(DISTINCT column)

  • COUNT(*) — все строки
  • COUNT(column) — ненулевые значения column
  • COUNT(DISTINCT column) — уникальные значения

Агрегат без GROUP BY

-- ошибка: user_id не агрегирован и не в GROUP BY
SELECT user_id, SUM(total) FROM orders;

-- правильно
SELECT user_id, SUM(total) FROM orders GROUP BY user_id;

NULL в агрегатах

SUM, AVG, COUNT(col) игнорируют NULL. Но AVG считает от только от не-NULL записей — если хотите от всех, используйте SUM / COUNT(*).

Смешивание разных гранулярностей

-- неправильно: sum orders и sum items в одном запросе — разные размерности
SELECT SUM(total), SUM(qty) FROM orders JOIN order_items USING (order_id);

Разносите по CTE или подзапросам.

Производительность

  • Индекс на GROUP BY-колонку ускоряет
  • COUNT(DISTINCT) — дороже, чем COUNT(*)
  • На больших данных: партиционирование по времени

Связанные темы

FAQ

Что спрашивают чаще всего по агрегации?

COUNT vs COUNT DISTINCT, WHERE vs HAVING, SUM с CASE (доля чего-то), группировка по дате.

Как отличить WHERE от HAVING?

WHERE — до GROUP BY, фильтрует строки. HAVING — после, фильтрует агрегаты.

COUNT(*) или COUNT(1) — что быстрее?

В современных СУБД — одинаково. Пишите COUNT(*), это стандарт.

Почему SUM возвращает NULL, а не 0 для пустой группы?

Стандарт SQL. Для принудительного 0 — COALESCE(SUM(col), 0).


Тренируйте SQL на реальных задачах — откройте тренажёр с 200+ задачами и разборами.