Подзапросы в SQL: частые ошибки новичков и их исправление
За 20–30 минут вы разберёте типичные ошибки в подзапросах на примерах для PostgreSQL 16: от лишних строк в скалярном запросе до неожиданного поведения NULL в NOT IN. Для каждого случая — исправленный запрос и способ проверить результат.

Подзапрос может вернуть одну ячейку, список значений или таблицу — и ошибка новичка часто начинается с того, что эти результаты считают взаимозаменяемыми. За 20–30 минут вы проверите пять типичных ловушек на PostgreSQL 16 и научитесь замечать, когда запрос не просто падает, а тихо возвращает не те строки.
Что понадобится
Нужна база PostgreSQL 16 и любой клиент, в котором можно выполнять SQL. Примеры не требуют таблиц: каждый запрос создаёт тестовые данные через CTE и работает отдельно. Если у вас другая СУБД, синтаксис и тексты ошибок могут отличаться.
Подзапрос1 — запрос, вложенный в другой SQL-запрос. Его допустимый результат зависит от места, где он стоит: например, условие EXISTS проверяет наличие строк, а выражение в списке SELECT обычно должно дать одно значение на строку.
Проверьте пять типичных ошибок
Скалярный подзапрос возвращает несколько строк. В позиции, где ожидается одно значение, запрос не может произвольно выбрать одну строку. PostgreSQL сообщит:
more than one row returned by a subquery used as an expression.WITH customers (id, name) AS ( VALUES (1, 'Анна'), (2, 'Борис') ), orders (customer_id, amount) AS ( VALUES (1, 120), (1, 80), (2, 50) ) SELECT c.name, (SELECT o.amount FROM orders AS o WHERE o.customer_id = c.id) AS amount FROM customers AS c;У Анны две покупки, поэтому подзапрос для неё возвращает две строки. Если нужна общая сумма, задайте это явно: замените выражение внутри подзапроса на
SELECT SUM(o.amount). Если нужна одна конкретная покупка, определите правило выбора — например, сортировку по дате и ограничение результата. Проверка: для каждого клиента запрос должен возвращать ровно одну строку с суммой или выбранной покупкой.Сравнение с подзапросом, который выдаёт список. Оператор
=сравнивает значение с одним значением, а не со множеством. Для списка используйтеINлибоEXISTS.WITH customers (id, name) AS ( VALUES (1, 'Анна'), (2, 'Борис'), (3, 'Вера') ), orders (customer_id) AS ( VALUES (1), (1), (3) ) SELECT c.name FROM customers AS c WHERE c.id IN ( SELECT o.customer_id FROM orders AS o );Запрос выбирает клиентов хотя бы с одним заказом. Повторы идентификаторов в результатах подзапроса не размножают строки внешнего запроса. Проверка: должны появиться Анна и Вера, но не Борис. Если нужна проверка наличия связанной строки, вместо списка можно применить
EXISTS.NOT INменяет смысл из-за NULL. Если список содержит NULL, сравнение с ним не даёт обычного «да» или «нет»: результат становится неизвестным, и строка не проходит WHERE. Это особенно неприятно, если запрос выглядит правдоподобно и не выдаёт ошибку.WITH customers (id) AS ( VALUES (1), (2), (3) ), blocked (customer_id) AS ( VALUES (2), (NULL::integer) ) SELECT c.id FROM customers AS c WHERE c.id NOT IN ( SELECT b.customer_id FROM blocked AS b );Этот запрос не вернёт ожидаемые 1 и 3. Для проверки отсутствия совпадения безопаснее сформулировать условие через
NOT EXISTS:WITH customers (id) AS ( VALUES (1), (2), (3) ), blocked (customer_id) AS ( VALUES (2), (NULL::integer) ) SELECT c.id FROM customers AS c WHERE NOT EXISTS ( SELECT 1 FROM blocked AS b WHERE b.customer_id = c.id );Проверка: результат — 1 и 3. Если NULL в исходных данных невозможен по ограничению таблицы, ловушка не проявится, но это стоит проверить, а не предполагать.
Подзапрос не связан с внешней строкой. В коррелированном подзапросе условие внутри ссылается на строку внешнего запроса. Если забыть связь, можно получить общий итог вместо отдельного результата для каждого клиента.
WITH customers (id, name) AS ( VALUES (1, 'Анна'), (2, 'Борис') ), orders (customer_id, amount) AS ( VALUES (1, 120), (1, 80), (2, 50) ) SELECT c.name, (SELECT SUM(o.amount) FROM orders AS o WHERE o.customer_id = c.id) AS total FROM customers AS c;Условие
o.customer_id = c.idсвязывает подзапрос с текущим клиентом. Уберите его — и оба клиента получат общую сумму всех заказов. Проверка: в результате Анна должна получить 200, Борис — 50.Агрегат и GROUP BY применены не к тому уровню. Если подзапрос считает сумму по клиентам, но внешний запрос смешивает эту сумму с обычными столбцами и группирует иначе, результат легко исказить или получить ошибку. Сначала определите гранулярность: одна строка на клиента, заказ или иной объект. Для задачи «клиенты, у которых сумма заказов выше 100» агрегируйте в подзапросе и сравнивайте готовое значение.
WITH orders (customer_id, amount) AS ( VALUES (1, 120), (1, 80), (2, 50) ) SELECT totals.customer_id, totals.total FROM ( SELECT o.customer_id, SUM(o.amount) AS total FROM orders AS o GROUP BY o.customer_id ) AS totals WHERE totals.total > 100;Проверка: в результате остаётся клиент с идентификатором 1 и суммой 200. Если добавляете в SELECT столбец, не входящий в агрегат, проверьте, что он согласован с группировкой.
Если запрос не сработал
more than one row returned by a subquery used as an expression— подзапрос в позиции одного значения вернул несколько строк. Добавьте агрегат, сузьте условие или задайте однозначное правило выбора строки.operator does not exist: ... = recordили ошибка несовместимых типов — проверьте, сколько столбцов возвращает подзапрос и совпадает ли тип с левой частью сравнения. Для одиночного сравнения подзапрос должен выдавать один столбец подходящего типа.Запрос выполняется, но строк нет — проверьте NULL в подзапросе для
NOT IN, условие связи с внешней строкой и фактические значения ключей.Результаты повторяются — выясните, не размножает ли строки соединение во внешнем запросе.
EXISTSудобен, когда нужно проверить наличие совпадения, а не вывести все совпавшие строки.
Что делать дальше
Возьмите один проблемный запрос и сначала запустите его внутреннюю часть отдельно. Посмотрите, сколько строк и столбцов она возвращает и встречается ли в них NULL. Затем проверьте полный результат на небольшом наборе данных, где ответ известен заранее. Если запрос корректен, но медленный, посмотрите план выполнения через EXPLAIN; не делайте вывод о производительности только по тому, что подзапрос записан в SQL. Оптимизатор PostgreSQL может преобразовывать запросы, а итог зависит от данных, индексов и плана.
Самая полезная проверка подзапроса — не «похоже ли это на SQL», а «сколько строк и столбцов он возвращает в каждом возможном случае».
1 Гранулярность — уровень детализации результата: например, одна строка на клиента или одна строка на заказ. Ошибки группировки часто начинаются с того, что эти уровни смешивают.
Источники и документация
Примеры ориентированы на PostgreSQL 16. Для другой версии или СУБД сверяйте поведение с её документацией.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: PostgreSQL 16: подзапросы в выражениях, PostgreSQL 16: выражения таблиц и подзапросы в FROM
Частые вопросы
Почему скалярный подзапрос возвращает ошибку, если в таблице есть подходящая строка?
Чем EXISTS отличается от IN?
Почему NOT IN не возвращает строки, хотя совпадений нет?
Подзапрос всегда выполняется отдельно для каждой строки внешнего запроса?
Можно ли перенести примеры на MySQL или SQLite?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.