Задачи на JOIN на собеседовании аналитика

Что спрашивают про JOIN

JOIN — абсолютный must на любом SQL-собесе. От junior до senior. Задачи обычно проверяют:

  • Правильно выбираете тип JOIN.
  • Умеете считать ANTI JOIN (NOT EXISTS / LEFT JOIN IS NULL).
  • Не дублируете строки при n:m.
  • Пишете SELF JOIN для иерархий.

Ниже 15 задач из реальных собесов — в Wildberries, Ozon, Тинькофф, Альфа, Сбере.

Базовые

1. Обогатить заказы именем пользователя

SELECT o.order_id, u.name, o.amount
FROM orders o
JOIN users u ON u.user_id = o.user_id;

2. Все пользователи с суммой заказов (включая 0)

SELECT u.user_id, u.name, COALESCE(SUM(o.amount), 0) AS total
FROM users u
LEFT JOIN orders o USING (user_id)
GROUP BY u.user_id, u.name;

COALESCE — чтобы вместо NULL у пользователей без заказов было 0.

3. Пользователи без заказов (ANTI JOIN)

SELECT u.*
FROM users u
LEFT JOIN orders o ON o.user_id = u.user_id
WHERE o.user_id IS NULL;

Альтернатива через NOT EXISTS (чаще быстрее):

SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.user_id);

4. Товары, которые никто не покупал за последний месяц

SELECT p.*
FROM products p
WHERE NOT EXISTS (
    SELECT 1 FROM order_items oi
    JOIN orders o USING (order_id)
    WHERE oi.product_id = p.product_id
      AND o.created_at >= CURRENT_DATE - INTERVAL '30 day'
);

Сложные связи

5. Сумма заказов без дублирования при 3 таблицах

Опасная задача: users → orders → order_items. Если просто JOIN всё:

-- ❌ SUM будет завышен, если у order несколько items
SELECT u.user_id, SUM(oi.price * oi.quantity)
FROM users u
JOIN orders o USING (user_id)
JOIN order_items oi USING (order_id)
GROUP BY u.user_id;

Нет, на самом деле тут SUM правильный, потому что мы суммируем elements. Ошибка была бы, если бы мы считали COUNT(DISTINCT o.order_id) * AVG(amount).

Правильная техника для таких запросов — агрегат до JOIN:

WITH order_totals AS (
    SELECT order_id, SUM(price * quantity) AS total
    FROM order_items GROUP BY order_id
)
SELECT u.user_id, SUM(t.total) AS revenue
FROM users u
JOIN orders o USING (user_id)
JOIN order_totals t USING (order_id)
GROUP BY u.user_id;

6. Количество уникальных категорий, в которых покупал user

SELECT o.user_id, COUNT(DISTINCT p.category) AS cat_count
FROM orders o
JOIN order_items oi USING (order_id)
JOIN products p USING (product_id)
GROUP BY o.user_id;

Больше таких примеров с разборами — в Telegram-тренажёре. Короткие сессии, прогресс по темам, объяснения после каждого ответа.

SELF JOIN

7. Иерархия менеджеров

SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id;

8. Сравнение заказов подряд (через SELF JOIN)

SELECT o1.user_id, o1.created_at, o1.amount AS curr, o2.amount AS prev
FROM orders o1
LEFT JOIN orders o2
    ON o2.user_id = o1.user_id
    AND o2.created_at = (
        SELECT MAX(created_at) FROM orders o3
        WHERE o3.user_id = o1.user_id AND o3.created_at < o1.created_at
    );

Часто переписывают через LAG — быстрее и читабельнее.

9. Пары пользователей, живущих в одном городе

SELECT u1.user_id, u2.user_id, u1.city
FROM users u1
JOIN users u2 ON u2.city = u1.city AND u2.user_id < u1.user_id;

Условие u2.user_id < u1.user_id исключает дубли и self-матч.

FULL OUTER JOIN

10. Сверка двух источников — где расхождения

SELECT COALESCE(a.id, b.id) AS id, a.val, b.val,
    CASE WHEN a.id IS NULL THEN 'only_in_b'
         WHEN b.id IS NULL THEN 'only_in_a'
         WHEN a.val != b.val THEN 'diff'
         ELSE 'ok' END AS status
FROM source_a a
FULL OUTER JOIN source_b b ON b.id = a.id;

Фильтр в JOIN vs WHERE

11. Все пользователи + сумма оплаченных заказов

-- ✅ Правильно: фильтр на правую таблицу в ON
SELECT u.user_id, COALESCE(SUM(o.amount), 0) AS paid
FROM users u
LEFT JOIN orders o ON o.user_id = u.user_id AND o.status = 'paid'
GROUP BY u.user_id;

-- ❌ Неправильно: WHERE превращает LEFT JOIN в INNER
SELECT u.user_id, COALESCE(SUM(o.amount), 0) AS paid
FROM users u
LEFT JOIN orders o ON o.user_id = u.user_id
WHERE o.status = 'paid'  -- исключит NULL, потеряем пользователей без paid
GROUP BY u.user_id;

Классическая ловушка на собесе.

CROSS JOIN

12. Генерация матрицы категория × день месяца

SELECT c.category, d.day
FROM categories c
CROSS JOIN generate_series(
    '2026-04-01'::DATE, '2026-04-30'::DATE, '1 day'::INTERVAL
) d(day);

13. Календарь всех дней с выручкой (включая дни с 0 продажами)

WITH cal AS (
    SELECT generate_series('2026-04-01'::DATE, '2026-04-30'::DATE, '1 day'::INTERVAL)::DATE AS day
)
SELECT c.day, COALESCE(SUM(o.amount), 0) AS revenue
FROM cal c
LEFT JOIN orders o ON o.created_at::DATE = c.day
GROUP BY c.day ORDER BY c.day;

Если готовишься к собесу — бот @kariernik_bot закрывает 80% технических вопросов. SQL, Python, A/B, продуктовые метрики — всё в одном месте.

Сложные

14. Топ-3 товара в каждой категории

WITH ranked AS (
    SELECT p.category, p.product_id, SUM(oi.quantity) AS sold,
        ROW_NUMBER() OVER (PARTITION BY p.category ORDER BY SUM(oi.quantity) DESC) AS rn
    FROM products p
    JOIN order_items oi USING (product_id)
    GROUP BY p.category, p.product_id
)
SELECT category, product_id, sold FROM ranked WHERE rn <= 3;

Комбо: JOIN + GROUP BY + оконка.

15. Пользователи, купившие все три товара A, B, C

SELECT user_id
FROM orders o
JOIN order_items oi USING (order_id)
WHERE oi.product_id IN ('A', 'B', 'C')
GROUP BY user_id
HAVING COUNT(DISTINCT oi.product_id) = 3;

HAVING COUNT(DISTINCT) = 3 — классика проверки «все элементы списка».


Как тренироваться

JOIN — тема, где легко налажать незаметно. Запрос работает, возвращает «какие-то» строки, и вы их сдаёте в отчёт. А потом оказывается, что суммы дублированы или пользователи потеряны.

Тренажёр Карьерник содержит блок JOIN-задач: дублирование строк, ANTI JOIN, SELF JOIN, FULL OUTER, фильтр в ON vs WHERE. Каждая с разбором ловушек.

Совет: когда решаете JOIN-задачу, всегда спрашивайте: «а что я ожидаю увидеть — строк меньше, столько же или больше, чем в левой таблице?» Если ответа нет — думайте ещё.

Читайте также

FAQ

Как решить ANTI JOIN правильнее — LEFT JOIN + IS NULL или NOT EXISTS?

NOT EXISTS обычно быстрее: останавливается на первом совпадении. LEFT JOIN + IS NULL тоже работает и всегда понятен. Для больших таблиц предпочтительнее NOT EXISTS.

Нужно ли явно писать INNER?

Нет, JOIN без OUTER = INNER JOIN. Но на собесе часто пишут явно — это читабельнее и сразу показывает намерение.

Можно ли JOIN по вычисляемому столбцу?

Можно: ON created_at::date = d.day. Но индексы могут не работать — проверяйте через EXPLAIN. Лучше вынести вычисление в CTE или заранее подготовленный столбец.

USING или ON?

USING — короче, столбец в SELECT один, а не два. Работает только если столбцы одноимённые. ON — универсальнее, поддерживает любые условия. На собесе знание обоих — плюс.