Подзапросы (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 было бы чище» и перепишите. Это ставит вам балл за читаемость.
Читайте также
- CTE (WITH) SQL: шпаргалка
- EXISTS vs IN в SQL
- CTE vs подзапрос в SQL
- JOIN SQL: шпаргалка
- Оконные функции SQL: шпаргалка
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 — они выполняются раньше.