Задачи на 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 — универсальнее, поддерживает любые условия. На собесе знание обоих — плюс.