Подзапросы в SQL: виды и примеры
Что такое подзапрос
Подзапрос (subquery) — это SELECT-запрос, вложенный в другой SQL-запрос. Внешний запрос использует результат внутреннего как обычное значение, список или таблицу.
Подзапросы — один из фундаментальных инструментов SQL. Они позволяют решать задачи, которые невозможно или неудобно решить одним плоским SELECT: фильтрация по вычисленному значению, сравнение строки с агрегатом, проверка существования связанных записей.
Простейший пример — найти сотрудников с зарплатой выше средней:
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees)Внутренний запрос SELECT AVG(salary) FROM employees выполняется первым и возвращает одно число. Внешний запрос использует это число в условии WHERE. База данных сначала считает среднюю зарплату, потом фильтрует сотрудников.
Подзапросы можно размещать в разных частях SQL-запроса: в WHERE, SELECT, FROM, HAVING и даже внутри JOIN. В зависимости от того, что возвращает подзапрос и где он используется, различают несколько видов.
Скалярные подзапросы
Скалярный подзапрос возвращает ровно одно значение — одну строку и один столбец. Это может быть число, строка, дата. Если подзапрос вернёт больше одной строки, база данных выдаст ошибку.
В WHERE
Самый распространённый случай — сравнение с агрегатом:
SELECT product_name, price
FROM products
WHERE price > (SELECT AVG(price) FROM products)
ORDER BY price DESCПодзапрос вычисляет среднюю цену по всем товарам. Внешний запрос отбирает товары дороже этого значения. Скалярный подзапрос работает здесь как константа — он выполняется один раз, и результат подставляется в условие.
В SELECT
Скалярный подзапрос в SELECT добавляет вычисляемый столбец к каждой строке:
SELECT
d.department_name,
d.budget,
(SELECT SUM(salary) FROM employees e WHERE e.department_id = d.id) AS total_salaries
FROM departments dДля каждого отдела запрос считает суммарные зарплаты сотрудников. Обратите внимание: подзапрос ссылается на d.id из внешнего запроса — это уже коррелированный подзапрос (о них ниже). Он выполняется для каждой строки внешнего запроса.
В HAVING
Подзапрос в HAVING фильтрует группы по сравнению с вычисленным значением:
SELECT
category,
AVG(price) AS avg_price
FROM products
GROUP BY category
HAVING AVG(price) > (SELECT AVG(price) FROM products)Находим категории, средняя цена товаров в которых выше общей средней цены. Подзапрос вычисляется один раз и используется как порог для фильтрации групп.
Табличные подзапросы
Табличный подзапрос возвращает набор строк и столбцов — фактически, временную таблицу. Его используют в FROM или JOIN, и обязательно дают алиас (псевдоним).
В FROM
SELECT
category,
avg_price,
product_count
FROM (
SELECT
category,
AVG(price) AS avg_price,
COUNT(*) AS product_count
FROM products
GROUP BY category
) category_stats
WHERE product_count >= 5
ORDER BY avg_price DESCВнутренний запрос вычисляет статистику по категориям. Внешний запрос работает с результатом как с обычной таблицей — фильтрует и сортирует. Это классический приём: сначала агрегировать, потом отфильтровать агрегаты. То же самое можно сделать через HAVING, но табличный подзапрос бывает удобнее, если нужна дополнительная логика поверх агрегатов.
В JOIN
SELECT
u.name,
u.email,
o.total_orders,
o.total_amount
FROM users u
JOIN (
SELECT
user_id,
COUNT(*) AS total_orders,
SUM(amount) AS total_amount
FROM orders
WHERE created_at >= '2026-01-01'
GROUP BY user_id
) o ON o.user_id = u.id
ORDER BY o.total_amount DESCПодзапрос агрегирует заказы за 2026 год, а затем результат джойнится к таблице пользователей. Это позволяет сначала сократить объём данных в подзапросе, а потом обогатить результат дополнительными полями из другой таблицы.
Подзапросы в WHERE: IN, EXISTS, ANY, ALL
Это самая богатая область применения подзапросов. Операторы IN, EXISTS, ANY и ALL позволяют строить сложные условия фильтрации, которые без подзапросов потребовали бы нескольких отдельных запросов.
IN с подзапросом
IN проверяет, входит ли значение в список, возвращаемый подзапросом:
SELECT name, email
FROM users
WHERE id IN (
SELECT DISTINCT user_id
FROM orders
WHERE created_at >= '2026-03-01'
)Находим пользователей, которые сделали хотя бы один заказ в марте 2026. Подзапрос возвращает список user_id, а внешний запрос фильтрует пользователей по этому списку.
NOT IN — обратная операция: найти тех, кого нет в списке.
SELECT name, email
FROM users
WHERE id NOT IN (
SELECT DISTINCT user_id
FROM orders
WHERE created_at >= '2026-01-01'
)Пользователи без заказов с начала года. Важный нюанс: если подзапрос вернёт хотя бы одно NULL-значение, NOT IN вернёт пустой результат. Это коварная ловушка — помните о ней и добавляйте WHERE user_id IS NOT NULL в подзапрос, если сомневаетесь.
EXISTS
EXISTS проверяет, возвращает ли подзапрос хотя бы одну строку. Не важно, какие данные в строке — важен сам факт существования:
SELECT u.name, u.email
FROM users u
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.id
AND o.created_at >= '2026-03-01'
)Результат тот же, что с IN выше, но механизм работы другой. EXISTS прекращает проверку, как только находит первую подходящую строку. Это делает его эффективным для таблиц с большим количеством записей.
EXISTS vs IN: когда что использовать
На практике различия в производительности зависят от размера таблиц и наличия индексов:
- EXISTS лучше, когда внутренняя таблица (та, что в подзапросе) большая, а внешняя — маленькая. EXISTS остановится на первом совпадении и не будет сканировать дальше.
- IN лучше, когда подзапрос возвращает небольшой набор уникальных значений. Оптимизатор может использовать хеш-таблицу для быстрого поиска.
- NOT EXISTS почти всегда лучше NOT IN, потому что NOT IN ломается на NULL-значениях и не может использовать ранний выход.
На современных СУБД (PostgreSQL 12+, MySQL 8+) оптимизатор часто сам преобразует IN в EXISTS и наоборот, если это выгоднее. Но знать разницу полезно — на более старых версиях или в сложных запросах это может быть критично.
ANY и ALL
ANY (синоним SOME) и ALL — менее распространённые, но полезные операторы. Они используются с операторами сравнения (=, >, <, >=, <=, <>).
ANY — условие истинно, если оно выполняется хотя бы для одного значения из подзапроса:
SELECT name, salary
FROM employees
WHERE salary > ANY (
SELECT salary
FROM employees
WHERE department_id = 3
)Находим сотрудников, чья зарплата выше хотя бы одной зарплаты в отделе 3. По сути, > ANY (...) эквивалентно > MIN(...) — достаточно быть больше минимального значения.
ALL — условие должно выполняться для всех значений из подзапроса:
SELECT name, salary
FROM employees
WHERE salary > ALL (
SELECT salary
FROM employees
WHERE department_id = 3
)Сотрудники, которые получают больше всех в отделе 3. > ALL (...) эквивалентно > MAX(...). ALL полезен, когда нужно жёсткое сравнение со всем набором значений. Если подзапрос вернёт пустой результат, условие с ALL считается истинным для всех строк — будьте внимательны.
Коррелированные подзапросы
Обычный подзапрос выполняется один раз. Коррелированный подзапрос ссылается на столбцы внешнего запроса и выполняется заново для каждой строки внешнего запроса. Это как цикл: для каждой строки внешнего запроса база вычисляет подзапрос с подставленными значениями этой строки.
Классический пример — найти сотрудников, которые получают больше среднего по своему отделу:
SELECT e.name, e.salary, e.department_id
FROM employees e
WHERE e.salary > (
SELECT AVG(e2.salary)
FROM employees e2
WHERE e2.department_id = e.department_id
)Для каждого сотрудника подзапрос считает среднюю зарплату именно его отдела. Затем зарплата сотрудника сравнивается с этим средним. Подзапрос ссылается на e.department_id — это и делает его коррелированным.
Ещё один пример — найти последний заказ каждого пользователя:
SELECT o.user_id, o.created_at, o.amount
FROM orders o
WHERE o.created_at = (
SELECT MAX(o2.created_at)
FROM orders o2
WHERE o2.user_id = o.user_id
)Для каждого заказа подзапрос находит максимальную дату заказа этого же пользователя. Если дата совпадает — значит, это последний заказ.
Почему коррелированные подзапросы медленнее
Коррелированный подзапрос выполняется для каждой строки внешнего запроса. Если во внешней таблице 100 000 строк, подзапрос выполнится 100 000 раз. Это O(n * m), где n — строки внешнего запроса, m — стоимость одного выполнения подзапроса.
Современные оптимизаторы умеют переписывать коррелированные подзапросы в JOIN, но не всегда. Если запрос работает медленно, попробуйте переписать его вручную:
-- Вместо коррелированного подзапроса
SELECT e.name, e.salary, e.department_id
FROM employees e
JOIN (
SELECT department_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY department_id
) d ON d.department_id = e.department_id
WHERE e.salary > d.avg_salaryЗдесь средняя зарплата вычисляется один раз для каждого отдела, а не для каждого сотрудника.
Подзапрос vs CTE vs JOIN
Подзапросы, CTE (WITH) и JOIN часто решают одну и ту же задачу. Выбор зависит от ситуации.
| Критерий | Подзапрос | CTE (WITH) | JOIN |
|---|---|---|---|
| Читаемость | Хорошая при 1 уровне вложенности, плохая при 2+ | Высокая — логика разбита на именованные шаги | Средняя — зависит от количества таблиц |
| Повторное использование | Нет — нужно дублировать | Да — одно имя, несколько ссылок | Нет |
| Производительность | Зависит от СУБД и типа подзапроса | Обычно аналогична подзапросу | Часто оптимальнее за счёт индексов |
| Когда использовать | Простая одноразовая фильтрация | Сложный запрос с 2+ шагами | Связывание таблиц, особенно с индексами |
Подзапрос — хорош для простых случаев. Фильтр по среднему, проверка через EXISTS, небольшой IN-список. Если вложенность не растёт — подзапрос компактнее.
CTE — незаменим, когда логика запроса сложная. Два-три шага подготовки данных, промежуточный результат используется несколько раз. CTE делает запрос самодокументируемым: имена CTE описывают, что происходит на каждом шаге.
JOIN — основной инструмент для связывания таблиц. Если задача сводится к «возьми данные из таблицы A и обогати данными из таблицы B» — используйте JOIN. Оптимизатор SQL лучше всего работает с JOIN-запросами, потому что это основная операция реляционной алгебры.
Правило: начните с JOIN. Если нужна фильтрация по агрегату — добавьте подзапрос. Если запрос стал нечитаемым — перепишите на CTE.
Практические задачи
Задача 1: товары без заказов
Найдите товары из таблицы products, на которые не было ни одного заказа в таблице order_items.
Решение через EXISTS:
SELECT p.product_name, p.price
FROM products p
WHERE NOT EXISTS (
SELECT 1
FROM order_items oi
WHERE oi.product_id = p.id
)NOT EXISTS — надёжнее, чем NOT IN, потому что корректно работает с NULL-значениями. Подзапрос ищет хотя бы одну строку в order_items для данного товара. Если строк нет — NOT EXISTS истинно, и товар попадает в результат.
Задача 2: лучший по зарплате в каждом отделе
Для каждого отдела найдите сотрудника с максимальной зарплатой.
Решение через коррелированный подзапрос:
SELECT e.name, e.department_id, e.salary
FROM employees e
WHERE e.salary = (
SELECT MAX(e2.salary)
FROM employees e2
WHERE e2.department_id = e.department_id
)Для каждого сотрудника подзапрос находит максимальную зарплату в его отделе. Если зарплата сотрудника совпадает с максимумом — он попадает в результат. Если в отделе несколько человек с одинаковой максимальной зарплатой — все они попадут в выборку.
Альтернатива — через оконную функцию:
SELECT name, department_id, salary
FROM (
SELECT
name, department_id, salary,
RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn
FROM employees
) ranked
WHERE rn = 1Задача 3: активные на прошлой неделе, но не на этой
Найдите пользователей, которые были активны с 24 по 30 марта, но не заходили с 31 марта по 6 апреля.
SELECT DISTINCT user_id
FROM user_sessions
WHERE session_date BETWEEN '2026-03-24' AND '2026-03-30'
AND user_id NOT IN (
SELECT DISTINCT user_id
FROM user_sessions
WHERE session_date BETWEEN '2026-03-31' AND '2026-04-06'
)Внутренний IN-подзапрос собирает тех, кто был активен на текущей неделе. NOT IN отсеивает их из списка активных на прошлой неделе. Такой запрос полезен для анализа оттока: мы видим пользователей, которые перестали заходить.
Задача 4: сравнение с общим средним
Для каждого сотрудника покажите его зарплату, среднюю зарплату по компании и отклонение от средней.
SELECT
name,
department_id,
salary,
(SELECT ROUND(AVG(salary), 2) FROM employees) AS company_avg,
salary - (SELECT ROUND(AVG(salary), 2) FROM employees) AS diff_from_avg
FROM employees
ORDER BY diff_from_avg DESCСкалярный подзапрос в SELECT вычисляется один раз (он не коррелированный — не ссылается на внешний запрос) и подставляется в каждую строку. Результат наглядно показывает, кто зарабатывает выше, а кто ниже среднего.
Альтернатива — через оконную функцию AVG(salary) OVER (), которая делает то же самое без подзапроса. Но версия с подзапросом проще для понимания.
Типичные ошибки
1. NOT IN с NULL-значениями
Это самая коварная ошибка при работе с подзапросами:
-- Если в orders.user_id есть NULL — результат будет пустым!
SELECT name
FROM users
WHERE id NOT IN (SELECT user_id FROM orders)Логика SQL: 5 NOT IN (1, 2, NULL) — это 5 <> 1 AND 5 <> 2 AND 5 <> NULL. Любое сравнение с NULL даёт UNKNOWN, и всё выражение становится UNKNOWN. Результат — ни одна строка не проходит фильтр.
Решение: используйте NOT EXISTS или добавьте WHERE user_id IS NOT NULL в подзапрос.
2. Подзапрос в SELECT без ограничения на одну строку
-- Ошибка: подзапрос может вернуть несколько строк
SELECT
name,
(SELECT order_date FROM orders WHERE user_id = users.id) AS last_order
FROM usersЕсли у пользователя несколько заказов, скалярный подзапрос вернёт несколько строк и запрос упадёт с ошибкой. Нужно гарантировать одну строку — через MAX, MIN, LIMIT 1 или агрегатную функцию:
SELECT
name,
(SELECT MAX(order_date) FROM orders WHERE user_id = users.id) AS last_order
FROM users3. Избыточная вложенность
-- Три уровня — уже тяжело читать
SELECT * FROM (
SELECT * FROM (
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
) inner_q
WHERE cnt > 5
) outer_q
JOIN users ON users.id = outer_q.user_idОдин уровень вложенности — нормально. Два — терпимо. Три и больше — сигнал, что пора переписать на CTE:
WITH active_buyers AS (
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 5
)
SELECT u.*, ab.cnt
FROM users u
JOIN active_buyers ab ON ab.user_id = u.idЧитаемость выросла, а результат тот же. HAVING убрал один лишний уровень вложенности, CTE — второй.
FAQ
Подзапрос или JOIN — что быстрее?
Однозначного ответа нет — зависит от СУБД, структуры данных и индексов. В большинстве случаев оптимизатор PostgreSQL и MySQL преобразует простые подзапросы в JOIN самостоятельно, и план выполнения будет одинаковым. Разница проявляется в сложных случаях: коррелированный подзапрос без индекса по столбцу связи будет значительно медленнее, чем JOIN с индексом. Общее правило: пишите так, как удобнее читать. Если запрос тормозит — смотрите EXPLAIN и оптимизируйте по факту, а не заранее.
Можно ли использовать подзапрос в UPDATE или DELETE?
Да, и это частая практика. В UPDATE подзапрос помогает обновить значения на основе данных из другой таблицы:
UPDATE products
SET price = price * 0.9
WHERE category_id IN (
SELECT id FROM categories WHERE name = 'Распродажа'
)В DELETE подзапрос задаёт условие для удаления:
DELETE FROM user_sessions
WHERE user_id NOT IN (
SELECT id FROM users WHERE is_active = TRUE
)Здесь удаляются сессии пользователей, которые больше не активны. Не забывайте про проблему NOT IN с NULL — в продакшн-запросах лучше использовать NOT EXISTS.
Что такое коррелированный подзапрос?
Коррелированный подзапрос — это подзапрос, который ссылается на столбцы из внешнего запроса. Из-за этого он не может выполниться самостоятельно — ему нужны данные из каждой строки внешнего запроса. Концептуально это цикл: для каждой строки внешнего запроса СУБД вычисляет подзапрос заново, подставляя значения текущей строки. Пример: WHERE salary > (SELECT AVG(salary) FROM employees e2 WHERE e2.department_id = e.department_id) — подзапрос коррелирован по department_id. Если запрос медленный, попробуйте переписать его через JOIN с предвычисленным агрегатом.
Когда лучше использовать CTE вместо подзапроса?
CTE стоит использовать, когда: (1) подзапрос нужен в нескольких местах — CTE позволяет объявить его один раз и ссылаться по имени; (2) вложенность подзапросов больше одного уровня — CTE выстраивает логику последовательно, а не вглубь; (3) вы хотите, чтобы запрос было легко понять коллегам — имена CTE работают как комментарии. Подзапрос уместнее, когда фильтр простой и одноразовый: WHERE id IN (SELECT ...) или WHERE salary > (SELECT AVG(...)). В таких случаях CTE добавит строк кода, но не добавит ясности.