Задачи на подзапросы SQL на собеседовании

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

Зачем нужны подзапросы

Подзапрос (subquery) — это SELECT внутри другого SELECT. На собеседованиях спрашивают, чтобы проверить, умеете ли вы:

  • Сводить сложную логику в один запрос
  • Работать с коррелированными подзапросами
  • Выбирать между subquery, CTE и JOIN
  • Понимать производительность

Типы подзапросов

  1. Скалярный — возвращает одно значение
  2. Столбец — возвращает список значений (с IN)
  3. Табличный — возвращает таблицу (в FROM)
  4. Коррелированный — ссылается на внешний запрос

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

Задача 1. Пользователи с заказами выше среднего

Таблица orders(user_id, total).

Найти user_id, у которых сумма заказов больше среднего по всем пользователям.

Решение:

SELECT user_id, SUM(total) AS user_total
FROM orders
GROUP BY user_id
HAVING SUM(total) > (SELECT AVG(user_total) FROM (
    SELECT SUM(total) AS user_total FROM orders GROUP BY user_id
) t);

Идея: внутренний подзапрос считает сумму по каждому, внешний — среднее этих сумм.


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

Таблица products(product_id, category, sales).

Выведите 3 товара с наибольшими продажами в каждой категории.

Решение через коррелированный подзапрос:

SELECT product_id, category, sales
FROM products p1
WHERE (
    SELECT COUNT(*) FROM products p2
    WHERE p2.category = p1.category AND p2.sales > p1.sales
) < 3;

Лучше — через оконную функцию:

SELECT product_id, category, sales
FROM (
    SELECT *,
        ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) AS rn
    FROM products
) t
WHERE rn <= 3;

На собесе упомяните оба — и отметьте, что оконные функции эффективнее.


Задача 3. Пользователи без заказов

users(user_id) и orders(user_id).

Решение 1 — NOT IN:

SELECT user_id FROM users
WHERE user_id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL);

Решение 2 — NOT EXISTS (безопаснее с NULL):

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

Решение 3 — LEFT JOIN:

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

На собесе покажите оба варианта. Отметьте, что NOT IN ломается на NULL в подзапросе.


Задача 4. Вторая максимальная зарплата

Таблица employees(id, salary).

Решение:

SELECT MAX(salary) FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);

Через DENSE_RANK:

SELECT salary FROM (
    SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
    FROM employees
) t
WHERE rnk = 2;

Задача 5. Процент от общей выручки

Для каждой категории — её доля в общей выручке.

Решение:

SELECT
    category,
    SUM(sales) AS cat_sales,
    SUM(sales) * 100.0 / (SELECT SUM(sales) FROM products) AS share_percent
FROM products
GROUP BY category;

Задача 6. Заказы, сделанные в первый день регистрации

users(user_id, created_at), orders(user_id, created_at, total).

Решение:

SELECT o.*
FROM orders o
WHERE o.created_at::DATE = (
    SELECT u.created_at::DATE FROM users u WHERE u.user_id = o.user_id
);

Или JOIN:

SELECT o.*
FROM orders o
JOIN users u ON u.user_id = o.user_id
WHERE o.created_at::DATE = u.created_at::DATE;

Задача 7. Пользователи, у которых каждый заказ больше 1000

Решение через подзапрос:

SELECT user_id FROM users
WHERE user_id IN (
    SELECT user_id FROM orders
    GROUP BY user_id
    HAVING MIN(total) > 1000
);

Задача 8. Последний заказ каждого пользователя

Коррелированный подзапрос:

SELECT * FROM orders o
WHERE created_at = (
    SELECT MAX(created_at) FROM orders
    WHERE user_id = o.user_id
);

Оконная функция (эффективнее):

SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
    FROM orders
) t
WHERE rn = 1;

Задача 9. Дубликаты записей

Найти строки, у которых (user_id, product_id) встречается больше 1 раза.

Решение:

SELECT * FROM orders
WHERE (user_id, product_id) IN (
    SELECT user_id, product_id FROM orders
    GROUP BY user_id, product_id
    HAVING COUNT(*) > 1
);

Задача 10. Заказы в тот же день, что последний заказ

Сравнение даты с последней датой из таблицы:

SELECT * FROM orders
WHERE created_at::DATE = (SELECT MAX(created_at)::DATE FROM orders);

Задача 11. Пользователи с заказами во всех категориях

Если в системе 3 категории, найти user_id, которые купили в каждой.

Решение:

SELECT user_id FROM orders
GROUP BY user_id
HAVING COUNT(DISTINCT category) = (SELECT COUNT(DISTINCT category) FROM orders);

Задача 12. Количество больше среднего по группе

Для каждой категории — товары, чьи продажи выше среднего в своей категории.

Решение:

SELECT * FROM products p1
WHERE sales > (
    SELECT AVG(sales) FROM products p2 WHERE p2.category = p1.category
);

Задача 13. Сравнение с предыдущим периодом

Выручка текущего месяца и предыдущего по категориям.

SELECT
    category,
    SUM(CASE WHEN month = DATE_TRUNC('month', CURRENT_DATE) THEN revenue END) AS cur,
    SUM(CASE WHEN month = DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 month') THEN revenue END) AS prev
FROM monthly_sales
GROUP BY category;

Или через подзапросы:

SELECT
    category,
    (SELECT SUM(revenue) FROM sales WHERE category = c.category AND month = ...) AS cur
FROM categories c;

Задача 14. EXISTS для условного фильтра

Пользователи, у которых был хотя бы один заказ после 2026 года.

SELECT * FROM users u
WHERE EXISTS (
    SELECT 1 FROM orders o
    WHERE o.user_id = u.user_id
      AND o.created_at > '2026-01-01'
);

Задача 15. Подзапрос в SELECT (скалярный)

Вывести для каждого заказа: его сумму и среднюю сумму заказа того же пользователя.

SELECT
    o.order_id,
    o.total,
    (SELECT AVG(total) FROM orders WHERE user_id = o.user_id) AS user_avg
FROM orders o;

В лоб — неоптимально при большом количестве строк. Через окно:

SELECT
    order_id,
    total,
    AVG(total) OVER (PARTITION BY user_id) AS user_avg
FROM orders;

Подзапрос vs CTE vs JOIN

Ситуация Что выбрать
Одноразовое использование Подзапрос (inline)
Использование результата несколько раз CTE (WITH)
Соединение таблиц JOIN
Фильтр «существует / не существует» EXISTS / NOT EXISTS
Топ-N в группе Оконная функция

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

NOT IN и NULL

-- если в подзапросе есть NULL, вернётся пустой результат
SELECT * FROM users WHERE user_id NOT IN (SELECT user_id FROM orders);

-- безопаснее
SELECT * FROM users WHERE user_id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL);

Коррелированный подзапрос в SELECT — медленно

Выполняется для каждой строки. На больших таблицах — узкое место. Переписывайте через JOIN или оконную функцию.

Забытый DISTINCT в IN

x IN (SELECT ...) не требует DISTINCT, но семантически иногда хочется. Обычно работает без него.

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

FAQ

Когда подзапрос лучше JOIN?

Когда нужно одно значение (скалярный), или фильтр «существует/не существует» (EXISTS). При соединении таблиц — JOIN эффективнее.

Когда подзапрос лучше CTE?

Для одноразового использования — подзапрос (меньше кода). Для многократного — CTE (читаемее + оптимизатор может кэшировать).

Что такое коррелированный подзапрос?

Это подзапрос, который ссылается на внешний запрос. Выполняется для каждой строки внешнего. На больших данных — медленный.

Почему NOT IN ломается на NULL?

Потому что NULL != value возвращает NULL, а не TRUE. Одна NULL в подзапросе — и весь NOT IN возвращает пусто. Используйте NOT EXISTS или LEFT JOIN.


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