Подзапросы в 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 users

3. Избыточная вложенность

-- Три уровня — уже тяжело читать
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 добавит строк кода, но не добавит ясности.