Подзапросы в SQL: 7 ошибок, которые меняют результат
Подзапрос может вернуть не одну строку, скрыть записи из-за NULL или размножить результат после JOIN. Разбираем типичные ошибки на примерах PostgreSQL 16 и показываем, как проверить запрос до выкладки.

Запрос с подзапросом может выглядеть правильно и при этом терять заказы из-за одного NULL, падать на второй найденной строке или возвращать дубликаты после JOIN. Ниже — практический разбор таких ловушек для PostgreSQL 16: как выбрать форму подзапроса, что проверить в результате и где смотреть план выполнения.
Сначала определите, что должен вернуть подзапрос
Подзапрос — это запрос внутри другого запроса. В PostgreSQL 16 он может использоваться как значение, как набор строк для проверки или как временный источник данных. Ошибка начинается, когда код предполагает один вариант, а фактически получает другой.
В примерах считаем, что есть таблицы orders с заказами и payments с платежами. У одного заказа может быть несколько платежей, а у некоторых заказов — ни одного. Именно такие кардинальности стоит явно учитывать в запросе.
Скалярный подзапрос: ноль строк — нормально, две — уже ошибка
Подзапрос в списке SELECT используется как одно значение. Он должен вернуть один столбец и не больше одной строки. Если строк нет, PostgreSQL подставит NULL; если строк несколько, запрос завершится ошибкой: more than one row returned by a subquery used as an expression.
SELECT
o.id,
(
SELECT p.amount
FROM payments AS p
WHERE p.order_id = o.id
) AS payment_amount
FROM orders AS o;
Если для заказа есть две записи платежа, этот запрос не выберет «подходящую» автоматически. Нужно определить правило. Например, чтобы получить сумму последнего платежа, сортируйте по времени, а затем по уникальному идентификатору: второй ключ разрешает ничью при одинаковом времени.
SELECT
o.id,
(
SELECT p.amount
FROM payments AS p
WHERE p.order_id = o.id
ORDER BY p.created_at DESC, p.id DESC
LIMIT 1
) AS latest_payment_amount
FROM orders AS o;
Это именно последний платеж, а не максимальная сумма. Если бизнес-правило другое, измените сортировку или используйте агрегат — но не маскируйте неопределённость случайным LIMIT 1.
NOT IN и NULL: пропадают строки, которых вы не исключали
Распространённая попытка найти заказы без платежей выглядит так:
SELECT o.id
FROM orders AS o
WHERE o.id NOT IN (
SELECT p.order_id
FROM payments AS p
);
Проблема возникает, если подзапрос содержит хотя бы один NULL в order_id. Для строк, которым не нашлось совпадения, выражение NOT IN тогда получает не TRUE, а UNKNOWN. Условие WHERE пропускает только TRUE — поэтому ожидаемые заказы исчезают из результата. NULL в самом o.id тоже не даст TRUE.
Для проверки отсутствия связанной строки обычно понятнее NOT EXISTS:
SELECT o.id
FROM orders AS o
WHERE NOT EXISTS (
SELECT 1
FROM payments AS p
WHERE p.order_id = o.id
);
Подзапрос здесь проверяет наличие хотя бы одной подходящей строки. Значение 1 в SELECT — условный маркер: содержимое строки не важно. Если внешний ключ или условие связи могут быть NULL, отдельно решите, должны ли такие записи считаться несвязанными.
IN, EXISTS и JOIN решают похожие, но не одинаковые задачи
Допустим, нужно получить заказы, у которых есть хотя бы один успешный платеж. Ниже три формы. При корректном условии они могут дать одинаковый набор идентификаторов, но JOIN способен повторить заказ, если платежей несколько.
| Форма | Подходит, когда | На что смотреть |
|---|---|---|
IN | Нужно проверить, входит ли значение в набор. | Проверьте NULL в наборе, особенно при отрицании через NOT IN. |
EXISTS | Важно только наличие подходящей строки. | Условие связи должно быть полным; дополнительные фильтры меняют смысл проверки. |
JOIN | Нужны столбцы обеих таблиц. | Несколько совпадений размножат строку заказа. |
Вариант с EXISTS выражает проверку наличия напрямую:
SELECT o.id
FROM orders AS o
WHERE EXISTS (
SELECT 1
FROM payments AS p
WHERE p.order_id = o.id
AND p.status = 'paid'
);
JOIN нужен, если в результате требуется, например, сумма платежа. Если же нужны только заказы, дубликаты можно убрать через DISTINCT, но сначала спросите себя, не скрывает ли он неверную связь или неожиданную кратность данных. DISTINCT исправляет форму вывода, а не причину размножения.
Если запрос отвечает на вопрос «есть ли хотя бы одна строка», пишите проверку наличия. Не превращайте её в соединение таблиц, пока вам действительно не нужны поля второй стороны.
Коррелированный подзапрос не означает автоматически медленный запрос
Коррелированный подзапрос* использует значение из внешнего запроса — например, o.id внутри условия для таблицы платежей. Из-за этого его часто называют «запросом на каждую строку» и заранее заменяют JOIN. Но это не гарантирует ускорения: оптимизатор PostgreSQL может преобразовать некоторые конструкции, а фактическая скорость зависит от данных, индексов и плана выполнения.
Сравнивайте варианты на реалистичном объёме данных. Для SELECT в PostgreSQL 16 план можно посмотреть командой EXPLAIN, а фактическое время и число обработанных строк — через EXPLAIN ANALYZE. Последняя команда действительно выполняет запрос, поэтому тяжёлую проверку проводите на копии данных или в подходящей среде. В плане обращайте внимание на повторяющиеся узлы, число циклов и расхождение между оценкой строк и фактическим результатом.
Когда что выбирать
- Нужно проверить принадлежность значению из набора — начните с
IN. Если подзапрос может возвращать NULL и нужна отрицательная проверка, рассмотритеNOT EXISTS. - Нужно проверить наличие или отсутствие связанной записи — выбирайте
EXISTSилиNOT EXISTS. Свяжите таблицы условием, которое соответствует предметной модели. - Нужны столбцы из обеих таблиц — используйте JOIN и заранее проверьте, сколько строк второй таблицы может совпасть с одной строкой первой.
- Нужно одно значение из связанной таблицы — задайте правило выбора: агрегат или сортировка с ограничением результата и однозначным дополнительным ключом.
Что ломается при проверке и в проде
На маленьком тестовом наборе подзапросы часто выглядят безупречно. Перед выкладкой проверьте граничные случаи: у родительской строки нет дочерних записей, их несколько, поле связи содержит NULL, значения сортировки совпадают. Для каждого случая заранее решите, какой результат считается правильным.
- Скалярный подзапрос возвращает несколько строк: определите нужную запись или примените корректную агрегацию.
NOT INнеожиданно возвращает пустой или неполный результат: проверьте NULL в подзапросе и рассмотритеNOT EXISTS.- После JOIN число строк выросло: посчитайте совпадения на одну запись и подтвердите ожидаемую связь по ключам.
- Запрос стал медленным на реальных данных: сравните планы и фактические строки, а не количество строк в текстовом SQL.
- Результат «последней записи» меняется между запусками: сортировка не задаёт уникальный порядок; добавьте ключ, который снимает неоднозначность.
* Коррелированный подзапрос — подзапрос, который обращается к столбцу из внешнего запроса; без такого обращения подзапрос не зависит от текущей внешней строки.
Что сделать дальше
Возьмите самый важный запрос с подзапросом и составьте для него четыре проверки: ноль совпадений, одно совпадение, несколько совпадений и NULL. Затем сравните ожидаемый результат с фактическим и снимите план на данных, близких к рабочим. Если меняете EXISTS на JOIN или наоборот, отдельно проверьте кратность строк: именно там чаще всего прячется разница, которую не видно на игрушечном наборе.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: PostgreSQL 16: выражения подзапросов, PostgreSQL 16: EXPLAIN
Частые вопросы
Почему NOT IN не находит строки, хотя совпадений нет?
Что произойдёт, если скалярный подзапрос вернёт несколько строк?
Коррелированный подзапрос всегда медленнее JOIN?
Можно ли использовать эти примеры в другой версии PostgreSQL?
Что проверить, если запрос работает, но выдаёт не те строки?
Есть ли риск запускать EXPLAIN ANALYZE на рабочей базе?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.