Что такое подзапрос
SELECT, завёрнутый в круглые скобки и вложенный внутрь другого запроса. С точки зрения реляционной алгебры это просто промежуточное отношение, которое мы вычисляем «на лету» и используем как операнд.
Откуда у этого инструмента три имени, которые нужно различать:
- скалярный подзапрос — возвращает ровно один столбец и ровно одну строку. С точки зрения внешнего запроса — обычное значение, неотличимое от литерала.
- многострочный подзапрос — один столбец, много строк. Это набор значений; в SQL он работает как операнд для
IN,NOT IN,ANY,ALL. - табличный подзапрос — много столбцов и много строк. Используется во
FROM(там его обязательно надо именовать черезAS) и вEXISTS.
В этом уроке разберём первые два. Табличные подзапросы во FROM и EXISTS уйдут в следующий урок про коррелированные подзапросы.
Подзапрос — это SELECT в скобках. Различаются они по тому, что именно возвращают: одно значение, одну колонку или целую таблицу.
Скалярный подзапрос
Контракт скаляра: ровно одна строка, ровно один столбец. Если ты соблюдаешь его, PostgreSQL разрешает писать подзапрос там же, где разрешён обычный литерал — в SELECT-списке, в WHERE-предикате, в ORDER BY, в CASE.
Классический сценарий — «средняя цена для сравнения»:
К каждому товару присоединим среднюю цену по всему каталогу — одним запросом, без JOIN
Что здесь происходит. Скаляр (SELECT AVG(price_cents)::INT FROM products) вычисляется один раз (планировщик его материализует как InitPlan), и подставляется в каждую строку как обычная константа. По сути, это удобный способ занести агрегат во внешний запрос, не нарушая GROUP BY-правила.
В WHERE это работает так же:
Товары дороже средней цены каталога — скаляр в роли границы
Эквивалент через JOIN потребовал бы сначала вычислить агрегат в подзапросе, потом джойнить — длиннее и менее читаемо. Скаляр сжимает это в одну строку.
Что если скаляр вернул больше одной строки
Это — главный сценарий, который ломает запросы в продакшене у новичков. Ты пишешь подзапрос, ожидая один ответ, а данные подкидывают сюрприз — и приходит две строки.
Этот запрос сломается. Прочитай ошибку — она важная.
Запусти — увидишь ERROR: more than one row returned by a subquery used as an expression. PostgreSQL отказывается «угадывать», какую из строк подставить. Это не warning, не fallback — это ошибка во время выполнения.
Правильные способы починить:
- Гарантировать одну строку через
LIMIT 1(плюс понятныйORDER BY, иначе результат недетерминирован). - Гарантировать одну строку через агрегат (
MAX,MIN,SUM— они всегда вернут ровно одну строку). - Переписать через JOIN, если на самом деле нужны все «совпадения».
Чиним: агрегат гарантирует одну строку
Тонкость: если подзапрос вернул ноль строк, скаляр становится NULL. Это не ошибка — это легальный результат. Поэтому всегда обрабатывай NULL в местах, где используешь скаляр.
Многострочный подзапрос — IN, NOT IN, ANY, ALL
Когда подзапрос возвращает много строк одного столбца, его нельзя сравнить через =. Зато можно через операторы множества.
IN — «значение слева совпадает хотя бы с одним из значений справа».
Все заказы клиентов из России — без JOIN, через IN + подзапрос
IN с подзапросом — это синтаксический сахар над ANY: x IN (SELECT ...) эквивалентно x = ANY (SELECT ...). А x > ANY (SELECT y FROM ...) означает «x больше хотя бы одного y». ALL — наоборот, «больше каждого».
ANY и ALL — полезные братья IN
> ALL (SELECT ...) ровно эквивалентно > (SELECT MAX(...)). Оптимизатор это знает и часто переписывает форму в другую. Но писать через MAX обычно понятнее.
NULL-ловушка в NOT IN
Это — второе классическое место, где новички теряются. Прочитай внимательно, потому что ошибка тут не падает с экраном — она тихо съедает строки.
Найдём клиентов, у которых birth_year не входит в маленький набор. Запусти и посчитай результат:
Если в подзапросе появляется NULL (а в customers.birth_year он есть), NOT IN начинает вести себя катастрофически:
x NOT IN (a, b, NULL) эквивалентно x != a AND x != b AND x != NULL. Последнее — это NULL. По правилам трёхзначной логики TRUE AND NULL = NULL, и общий предикат становится NULL — значит, строка отсекается.
В итоге NOT IN с подзапросом, в котором может появиться NULL, возвращает либо пустой результат, либо часть строк, в зависимости от данных. Это всегда баг.
Лечение: либо явно отфильтровать NULL внутри подзапроса (WHERE col IS NOT NULL), либо использовать NOT EXISTS — он работает корректно с NULL. Подробно про EXISTS — в следующем уроке.
Чиним NOT IN — гасим NULL до того, как он попадёт в подзапрос
Row-subquery — несколько колонок одной строки
PostgreSQL поддерживает редкую, но красивую форму: сравнение row-конструктора со скалярным подзапросом, возвращающим много колонок одной строки.
Найди клиента с email и страной, совпадающими с конкретным шаблоном из подзапроса
Здесь подзапрос возвращает одну строку из двух колонок, и мы сравниваем его с парой (email, country). Это и есть row subquery в стандарте. Используется редко, но удобно, когда нужно проверить совпадение по композитному ключу.
Чек-лист
- Подзапрос — это
SELECTв скобках, по форме результата делится на скалярный, многострочный и табличный. - Скаляр обязан вернуть ровно одну строку из одного столбца. Ноль строк —
NULL. Больше одной — runtime error. - Многострочный подзапрос работает с
IN,NOT IN,ANY,ALL.IN— сахар над= ANY. NOT INломается, если в подзапросе может бытьNULL— выкидывает строки молча. ЛечитсяIS NOT NULLили переписыванием черезNOT EXISTS.- Row subquery
(a, b) = (SELECT x, y FROM ...)— стандартный, но редко встречающийся способ сравнить композитный ключ за один шаг.