Подзапросы (subquery) в SQL: шпаргалка

Зачем аналитику подзапросы

Подзапросы — первое, чему учишься в SQL после SELECT и WHERE. Но на собеседовании умение выбирать между subquery, CTE и JOIN — признак зрелости.

Подзапрос — SELECT внутри другого SELECT. Он позволяет вычислить промежуточный результат и использовать его в основном запросе. Это старый механизм, сохранившийся с ранних версий SQL, и знать его нужно, потому что на собесе часто просят написать «через подзапрос» — и отличия от CTE.

Если готовитесь к собеседованию по SQL, этот раздел должен быть в рефлексах наравне с JOIN и GROUP BY.

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

1. Скалярный подзапрос

Возвращает одно значение — одну строку и один столбец. Можно ставить куда угодно, где допустимо значение:

SELECT
    order_id,
    amount,
    amount - (SELECT AVG(amount) FROM orders) AS deviation_from_avg
FROM orders;

Подзапрос (SELECT AVG(amount) FROM orders) вернёт одно число, и к нему можно вычесть строку.

2. Многострочный подзапрос

Возвращает список значений — используется с IN, ANY, ALL:

SELECT *
FROM products
WHERE category_id IN (
    SELECT id FROM categories WHERE name LIKE 'Электроника%'
);

3. Табличный подзапрос (derived table)

Возвращает таблицу, которую можно использовать в FROM или JOIN:

SELECT t.user_id, t.total
FROM (
    SELECT user_id, SUM(amount) AS total
    FROM orders
    GROUP BY user_id
) t
WHERE t.total > 1000;

Важно дать ему alias (в PostgreSQL и MySQL — обязательно).

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

Подзапрос, который ссылается на внешнюю таблицу:

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

Для каждой строки внешнего запроса выполняется свой подзапрос. Это может быть очень медленно при большой таблице — частый повод для собеседовательных ловушек.

Подзапрос в SELECT

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

SELECT
    user_id,
    SUM(amount) AS total,
    (SELECT COUNT(*) FROM orders) AS all_orders,
    SUM(amount) * 100.0 / (SELECT SUM(amount) FROM orders) AS share_pct
FROM orders
GROUP BY user_id;

Удобно, когда нужна отсылка к общему значению. На практике читабельнее написать через CROSS JOIN или CTE, но подзапрос в SELECT — короче.

Если хочется сразу закрепить тему на практике — открой тренажёр в Telegram. 10 минут в день — и синтаксис в пальцах.

Подзапрос в WHERE

С IN

SELECT *
FROM users
WHERE user_id IN (
    SELECT user_id FROM orders WHERE amount > 10000
);

Выбрать всех пользователей, у которых был заказ дороже 10 000.

С NOT IN

⚠️ Ловушка: если подзапрос возвращает NULL, весь NOT IN вернёт 0 строк:

-- Если в списке есть NULL, результат будет пустой
SELECT * FROM users WHERE user_id NOT IN (SELECT referred_by FROM users);

Решение: добавить WHERE referred_by IS NOT NULL в подзапрос, или использовать NOT EXISTS.

С EXISTS / NOT EXISTS

Более безопасная и часто более быстрая альтернатива IN:

SELECT u.*
FROM users u
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.user_id = u.user_id AND o.amount > 10000
);

EXISTS возвращает TRUE, как только нашёл хоть одну подходящую строку — дальше не копает. На больших данных это быстрее IN.

Подробнее — EXISTS vs IN в SQL.

С ANY / ALL

Реже используется, но бывает:

-- amount больше ЛЮБОГО значения из подзапроса = больше минимума
SELECT * FROM orders WHERE amount > ANY (SELECT amount FROM orders WHERE status = 'refund');

-- amount больше ВСЕХ значений = больше максимума
SELECT * FROM orders WHERE amount > ALL (SELECT amount FROM orders WHERE status = 'refund');

На собесе спрашивают как «бонус». Основной инструмент — IN / EXISTS.

Подзапрос в FROM

Derived table — использовать SELECT как таблицу:

SELECT city, AVG(total_spent) AS avg_per_user
FROM (
    SELECT user_id, city, SUM(amount) AS total_spent
    FROM orders JOIN users USING (user_id)
    GROUP BY user_id, city
) user_stats
GROUP BY city;

Сначала посчитали суммарные расходы каждого пользователя, потом усреднили по городам. Если делать одним запросом с двойным GROUP BY — неправильно (AVG(SUM(amount)) != AVG в итоговой).

Subquery vs CTE vs JOIN

Частый вопрос на собесе — какую конструкцию когда использовать:

Ситуация Лучший выбор
Простая фильтрация по значениям IN / EXISTS (subquery)
Сложная промежуточная таблица CTE
Переиспользование логики в одном запросе CTE
Обогащение строк данными из другой таблицы JOIN
Один столбец (скаляр) в SELECT scalar subquery
Пошаговая логика для читаемости CTE

На собесе: сказать «все три могут работать, но я выберу X потому что...». Плохой ответ: «subquery всегда медленнее JOIN» — это миф. Оптимизатор в современных СУБД приводит их к эквивалентным планам.

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

Самая частая ловушка — коррелированный подзапрос в большой таблице:

-- 💀 Может быть очень медленно
SELECT o.*,
    (SELECT COUNT(*) FROM orders o2 WHERE o2.user_id = o.user_id) AS user_orders_count
FROM orders o;

Для каждой строки из orders выполняется отдельный COUNT — N подзапросов на таблицу из N строк = N² сложность. Решение — через оконную функцию:

SELECT o.*,
    COUNT(*) OVER (PARTITION BY user_id) AS user_orders_count
FROM orders o;

Один проход, намного быстрее. На собеседовании это почти всегда правильный ответ на «как ускорить запрос».

Чтобы не только читать теорию, но и решать реальные задачи — загляните в бот Карьерника. Там по каждой теме подборка вопросов с разборами.

10 задач на подзапросы

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

SELECT user_id, amount
FROM orders
WHERE amount > (SELECT AVG(amount) FROM orders);

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

SELECT name
FROM categories c
WHERE (SELECT COUNT(*) FROM products p WHERE p.category_id = c.id) > 100;

Коррелированный подзапрос — работает, но через JOIN + GROUP BY быстрее:

SELECT c.name
FROM categories c JOIN products p ON p.category_id = c.id
GROUP BY c.id, c.name
HAVING COUNT(*) > 100;

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

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

Или через NOT EXISTS:

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

Задача 4. Второй по выручке пользователь

SELECT user_id, total
FROM (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) t
ORDER BY total DESC
OFFSET 1 LIMIT 1;

Классический подзапрос в FROM. Альтернатива — через оконную ROW_NUMBER.

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

SELECT * FROM products
WHERE product_id NOT IN (
    SELECT DISTINCT product_id FROM orders
    WHERE created_at >= CURRENT_DATE - INTERVAL '30 day'
      AND product_id IS NOT NULL
);

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

SELECT
    category,
    SUM(amount) AS rev,
    ROUND(100.0 * SUM(amount) / (SELECT SUM(amount) FROM orders), 2) AS pct
FROM orders JOIN products USING (product_id)
GROUP BY category;

Скалярный подзапрос даёт общее значение для расчёта процентов.

Задача 7. Заказы у пользователей из городов с более 1000 пользователей

SELECT *
FROM orders
WHERE user_id IN (
    SELECT user_id FROM users
    WHERE city IN (
        SELECT city FROM users GROUP BY city HAVING COUNT(*) > 1000
    )
);

Вложенные подзапросы — типовое для собесов. Можно переписать через CTE.

Задача 8. Найти дубликаты по email

SELECT * FROM users
WHERE email IN (
    SELECT email FROM users
    GROUP BY email HAVING COUNT(*) > 1
);

Задача 9. Пользователи, купившие товары из всех трёх категорий

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

Эта задача без подзапросов, но иллюстрирует паттерн «все из списка».

Задача 10. Ранжирование через коррелированный подзапрос

SELECT o.order_id, o.amount,
    (SELECT COUNT(*) FROM orders o2 WHERE o2.amount >= o.amount) AS rank
FROM orders o;

Старый способ посчитать ранг без оконных функций. Медленно, но понятно. На собесе стоит упомянуть, что через ROW_NUMBER() OVER (ORDER BY amount DESC) быстрее.


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

Подзапросы учатся в связке с пониманием «когда subquery, когда CTE, когда JOIN». Если вы хотите один ответ на все ситуации — такого нет. Нужно чувствовать.

Тренажёр Карьерник содержит блок подзапросов: скалярные, коррелированные, NOT IN vs NOT EXISTS, переписывание subquery в JOIN и обратно. Каждая задача с разбором — когда какой способ выигрывает.

Совет: если на собесе видите вложенные подзапросы на 3+ уровня — скажите «через CTE было бы чище» и перепишите. Это ставит вам балл за читаемость.

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

FAQ

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

Подзапрос, который ссылается на столбцы из внешнего запроса. Для каждой строки внешнего запроса выполняется отдельный проход подзапроса. Это часто работает медленно (N²) на больших таблицах — современные СУБД иногда превращают их в хеш-джоины, но не всегда.

Что быстрее — IN или EXISTS?

Раньше считалось, что EXISTS быстрее для больших таблиц, IN для маленьких. В современных СУБД (PostgreSQL 12+, SQL Server 2019+) оптимизатор обычно генерирует один и тот же план. Практическое правило: если в IN может быть NULL — используйте EXISTS, он безопаснее.

Subquery в SELECT vs JOIN vs CTE?

По функциональности эквивалентны в большинстве случаев. По читаемости: CTE > JOIN > subquery в SELECT. По производительности: зависит от оптимизатора, обычно одинаково. На собесе выбирайте по читаемости — интервьюеры ценят чистый код.

Можно ли в подзапросе использовать столбцы из основного SELECT?

Только в коррелированных подзапросах, и только из таблиц, которые уже в FROM основного запроса. Подзапросы в FROM (derived table) не могут ссылаться на основной SELECT — они выполняются раньше.