13. Подзапросы + EXISTS + IN
🧭 Введение: когда одного JOIN уже мало
В реальных запросах нужно не только соединять таблицы, но и проверять условия вида:
«есть ли связанные записи», «входит ли значение в набор», «нет ли запрещённых строк».
«есть ли связанные записи», «входит ли значение в набор», «нет ли запрещённых строк».
Для этого используются подзапросы в
Именно здесь чаще всего появляются логические баги: дубли, неверные фильтры и ловушки с
WHERE, коррелированные подзапросы, EXISTS, IN и anti-join-паттерны.Именно здесь чаще всего появляются логические баги: дубли, неверные фильтры и ловушки с
NULL.💡 Совет:
Перед написанием подзапроса формулируйте вопрос в одном предложении:
«я проверяю существование строки», «я сравниваю с набором значений» или «я исключаю связанные записи».
«я проверяю существование строки», «я сравниваю с набором значений» или «я исключаю связанные записи».
✅ Вывод:
Подзапросы и
EXISTS/IN нужны не для «сложности SQL», а для точной бизнес-логики фильтрации.⚠️ Проблема -> решение
Типичная проблема:
- разработчик пишет
IN/NOT IN«по привычке», - не учитывает
NULLи кардинальность, - получает тихо неверный результат в отчёте.
Последствия:
- ошибочные сегменты пользователей,
- неверные бизнес-метрики,
- тяжёлые запросы из-за неудачной формы подзапроса.
Решение:
- выбирать конструкцию по смыслу (
EXISTS,IN,NOT EXISTS), - явно учитывать поведение
NULL, - проверять план выполнения и переписывать подзапрос при необходимости.
🟢 Если совсем просто:
Сначала выбираем правильную логику (проверка существования или принадлежность набору), потом оптимизируем.
🎯 Как понять, что этап прошёл успешно:
Вы можете объяснить, почему выбрали именно
EXISTS или IN, и что будет при NULL.🛠️ Чем помогает и как работает
Подзапросы помогают решать типовые задачи:
- выбрать клиентов с оплаченными заказами;
- найти пользователей без активности;
- проверить принадлежность к whitelist/blacklist;
- отфильтровать строки по агрегированным условиям.
🟢 Если совсем просто:
Подзапрос в
WHERE позволяет «спросить БД» дополнительный вопрос про строку.🎯 Как понять, что этап прошёл успешно:
Вы по формулировке задачи заранее понимаете, нужен
EXISTS, IN или anti-join.Чем помогает:
INудобно сравнивает значение с набором;EXISTSбыстро проверяет факт наличия строки;- коррелированный подзапрос добавляет проверку «для текущей строки»;
NOT EXISTSбезопасно реализует anti-join.
Как это работает:
- Шаг 1: формулируем условие фильтра.
- Шаг 2: выбираем тип подзапроса (некоррелированный или коррелированный).
- Шаг 3: проверяем
NULL-поведение (IN/NOT INособенно критичны). - Шаг 4: сравниваем эквивалентные формы (
EXISTS,JOIN, CTE) по плану. - Шаг 5: закрепляем итог в читаемом production-запросе.
✅ Вывод:
Сильный SQL здесь начинается с корректной семантики, а не с «самого короткого синтаксиса».
📚 Ключевые термины (простыми словами)
Перед практикой синхронизируем словарь.
🟢 Если совсем просто:
Термины ниже отвечают на вопрос «как подзапрос взаимодействует с текущей строкой».
🎯 Как понять, что этап прошёл успешно:
Вы различаете
IN, EXISTS, NOT EXISTS и понимаете, где опасен NOT IN.- Subquery (подзапрос) —
SELECT, вложенный в другой запрос. - Subquery in WHERE — подзапрос, который используется как фильтр.
- Correlated subquery (коррелированный подзапрос) — подзапрос, ссылающийся на внешнюю строку.
- EXISTS — проверяет факт наличия хотя бы одной строки.
- IN — проверяет, входит ли значение в набор.
- Anti-join — выбор строк, у которых нет пары в другой таблице.
- Semi-join — выбор строк, у которых пара есть (обычно через
EXISTS). - NULL trap — ситуация, когда
NOT INдаёт неожиданный результат из-заNULLвнутри набора.
🧨 NULL trap (NOT IN) на пальцах:
Если подзапрос для
NOT IN вернул хотя бы один NULL, условие может стать UNKNOWN, и строки «исчезнут».Пример-иллюстрация:
-- Подзапрос вернул (1, NULL)-- Тогда 2 NOT IN (1, NULL) => UNKNOWN => строка отфильтруется✅ Практическое правило:
- anti-join по умолчанию =
NOT EXISTS; - если нужен
NOT IN, жестко исключайтеNULLвнутри подзапроса.
WHERE u.id NOT IN ( SELECT o.user_id FROM orders o WHERE o.user_id IS NOT NULL)✅ Вывод:
Эти понятия покрывают 90% задач фильтрации через подзапросы.
🔎 1. Подзапросы в WHERE (некоррелированные)
Некоррелированный подзапрос не зависит от текущей строки внешнего запроса.
Он вычисляется как самостоятельный набор значений, после чего используется в фильтре.
Он вычисляется как самостоятельный набор значений, после чего используется в фильтре.
🟢 Если совсем просто:
Сначала БД получает список, потом проверяет вхождение внешних строк в этот список.
🎯 Как понять, что этап прошёл успешно:
Вы можете заменить подзапрос на «готовый список значений» без потери смысла.
Назначение:
Фильтровать строки внешней таблицы по заранее рассчитанному набору значений.
Простыми словами:
IN (subquery) = «поле должно быть в списке, который вернул внутренний запрос».Для новичка:
Убедитесь, что подзапрос возвращает одну колонку и понятный набор значений.
Аналогия:
Это как сверка документа с заранее подготовленным списком разрешённых ID.
Пример:
SELECT u.id, u.emailFROM users uWHERE u.id IN ( SELECT o.user_id FROM orders o WHERE o.status = 'paid');🔎 Как это происходит на практике:
- Контекст: нужен сегмент «покупали хотя бы раз».
- Действия: в подзапросе собирают
user_idоплаченных заказов. - Результат: внешний запрос возвращает только нужных пользователей.
Характеристики:
- просто читать в базовых кейсах;
- удобно для «принадлежности набору»;
- может проигрывать
EXISTSна больших наборах и сложных условиях.
Когда использовать:
Когда логика действительно «значение входит в набор».
✅ Вывод:
IN удобен как стартовый вариант для простых фильтров по списку значений.🔁 2. Коррелированные подзапросы
Коррелированный подзапрос ссылается на текущую строку внешнего запроса.
Его удобно применять, когда условие должно проверяться индивидуально для каждой строки.
Его удобно применять, когда условие должно проверяться индивидуально для каждой строки.
🟢 Если совсем просто:
Для каждой строки снаружи выполняется проверка внутри.
🎯 Как понять, что этап прошёл успешно:
Вы видите связь между алиасом внешнего запроса и условием внутри подзапроса.
Назначение:
Проверять зависимое условие «для конкретной строки» внешней таблицы.
Простыми словами:
Подзапрос «смотрит» на текущий
u.id и отвечает: есть ли подходящая запись.Для новичка:
Чаще всего коррелированный подзапрос пишут с
EXISTS.Аналогия:
Это как отдельная проверка досье для каждого сотрудника в списке.
Пример:
SELECT u.id, u.emailFROM users uWHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.status = 'paid');Пример коррелированного подзапроса не через EXISTS (условие по агрегату):
SELECT u.id, u.emailFROM users uWHERE ( SELECT MAX(o.created_at) FROM orders o WHERE o.user_id = u.id) >= DATE '2026-03-01';Комментарий:
Это тоже коррелированный подзапрос; на больших данных его часто переписывают через
JOIN или окна, но по смыслу он очень наглядный.🔎 Как это происходит на практике:
- Контекст: API «клиенты с хотя бы одним paid-заказом».
- Действия:
EXISTSпроверяет факт наличия строки вorders. - Результат: по пользователю возвращается максимум одна внешняя строка, без дублей из JOIN.
Характеристики:
- точно выражает логику «есть/нет»;
- хорошо сочетается с индексами по ключу связи;
- обычно читается проще anti-join через сложные
JOINв фильтре.
Когда использовать:
Когда нужно проверить наличие зависимых записей для текущей строки.
✅ Вывод:
Коррелированный
EXISTS — базовый production-паттерн для условий «есть связанная запись».⚖️ 3. EXISTS vs IN: что выбрать
EXISTS и IN часто дают одинаковый результат, но лучше подходят под разные намерения.Ключевой вопрос: вы проверяете факт существования или принадлежность конкретному набору?
💡 Эвристика выбора:
INудобен для справочников/whitelist (небольшой набор значений, «список разрешённых/запрещённых»).EXISTSчаще лучше для транзакционных таблиц (orders,events), где важен факт «есть хотя бы одна строка».
🟢 Если совсем просто:
EXISTS = «нашлась хотя бы одна строка», IN = «значение входит в набор».🎯 Как понять, что этап прошёл успешно:
Вы можете для одной задачи написать оба варианта и объяснить выбор.
Назначение:
Выбрать конструкцию, которая точнее отражает бизнес-смысл и лучше масштабируется.
Простыми словами:
Если нужна логика «есть связанная строка», обычно берите
EXISTS.Для новичка:
На больших данных и сложных условиях чаще стартуют с
EXISTS.Аналогия:
IN — сверка со списком, EXISTS — проверка факта наличия в архиве.Пример:
-- Вариант через INSELECT u.id, u.emailFROM users uWHERE u.id IN ( SELECT o.user_id FROM orders o WHERE o.status = 'paid'); -- Эквивалентная логика через EXISTSSELECT u.id, u.emailFROM users uWHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.status = 'paid');🔎 Как это происходит на практике:
- Контекст: ревью SQL для фильтрации клиентов.
- Действия: сравнивают
INиEXISTS, выбирают более читаемый и стабильный вариант. - Результат: команда фиксирует единый стандарт для условий «есть связанные записи».
Характеристики:
INпроще в коротких списках и справочниках;EXISTSлучше отражает semi-join-семантику;- оптимизатор часто умеет переписать формы, но это зависит от БД и запроса.
Когда использовать:
IN — когда мыслите набором значений; EXISTS — когда мыслите фактом связанной строки.✅ Вывод:
Выбирайте конструкцию по смыслу задачи, не по привычке.
🚫 4. Anti-join: как выбрать строки «без пары»
Anti-join нужен, когда нужно найти строки, у которых нет соответствия в другой таблице.
Самый надёжный практический шаблон —
Самый надёжный практический шаблон —
NOT EXISTS.⚠️ Важно про смысл anti-join:
«Пользователь без заказов» ≠ «пользователь без paid-заказов».
Если вы ищете «без paid», фильтр
Если вы ищете «без paid», фильтр
status = 'paid' должен быть внутри NOT EXISTS (или в ON у LEFT JOIN), иначе смысл сегмента меняется.🟢 Если совсем просто:
Нужно вернуть строки, для которых «не нашлось связанной записи».
🎯 Как понять, что этап прошёл успешно:
Вы избегаете
NOT IN с подзапросом, где потенциально может встретиться NULL.Назначение:
Безопасно выбирать «осиротевшие» или неактивные сущности.
Простыми словами:
NOT EXISTS = «в связанной таблице нет ни одной подходящей строки».Для новичка:
NOT IN с NULL в подзапросе — частая ловушка, из-за которой можно получить пустой результат.Аналогия:
Поиск сотрудников, у которых нет ни одной активной задачи.
Пример:
-- Рекомендуемый anti-joinSELECT u.id, u.emailFROM users uWHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.status = 'paid'); -- Альтернатива через LEFT JOINSELECT u.id, u.emailFROM users uLEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid'WHERE o.id IS NULL;🔎 Как это происходит на практике:
- Контекст: сегмент «пользователи без покупок».
- Действия: используют
NOT EXISTSвместоNOT IN. - Результат: корректный anti-join даже при
NULLв связанных данных.
Характеристики:
NOT EXISTSбезопасен приNULL;LEFT JOIN ... IS NULLтоже рабочий anti-join-паттерн;NOT IN (subquery)требует строгого контроля отсутствияNULL.
Когда использовать:
Для фильтров вида «нет связанных записей».
✅ Вывод:
Для anti-join в production по умолчанию выбирайте
NOT EXISTS.⚡ 5. Производительность подзапросов: базовые правила
Подзапрос может быть как быстрым, так и очень дорогим — всё зависит от кардинальности, индексов и формы условия.
Хорошая новость: базовые оптимизации здесь понятны и повторяемы.
Хорошая новость: базовые оптимизации здесь понятны и повторяемы.
💡 Практический маркер:
Если подзапрос для
для логики «есть связь» чаще стабильнее
IN возвращает очень большой набор (миллионы значений), запрос может стать тяжёлым;для логики «есть связь» чаще стабильнее
EXISTS.🟢 Если совсем просто:
Фильтруйте раньше, проверяйте индексы и не усложняйте условие
ON/WHERE лишними функциями.🎯 Как понять, что этап прошёл успешно:
Вы умеете назвать 3 причины медленного подзапроса и как их убрать.
Назначение:
Сделать подзапросы предсказуемыми по времени выполнения.
Простыми словами:
Сначала уменьшаем объём данных, потом проверяем существование/принадлежность.
Для новичка:
Функции и приведения типов в условии (
lower(email), id::text) могут ломать индексный путь.Аналогия:
Это как искать книгу: быстрее по каталогу, чем вручную по всей библиотеке.
Пример:
SELECT u.id, u.emailFROM users uWHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.created_at >= DATE '2026-01-01' AND o.created_at < DATE '2026-02-01' AND o.status = 'paid');🔎 Как это происходит на практике:
- Контекст: endpoint сегментации работает медленно.
- Действия: переписывают
INвEXISTS, добавляют индекс наorders(user_id, status, created_at). - Результат: запрос стабильно укладывается в SLA.
Характеристики:
EXISTSхорошо работает для semi-join-проверок;- ранние фильтры уменьшают объём;
- индексы на ключах связи и фильтрах критичны;
EXPLAINобязателен для тяжёлых вариантов.
Когда использовать:
Всегда, когда подзапрос работает на таблицах с большим объёмом данных.
✅ Вывод:
Производительность подзапросов управляется инженерной дисциплиной, а не «магией синтаксиса».
🆚 Сравнение: EXISTS, IN и anti-join
| Конструкция | Семантика | Когда удобна | Риск |
|---|---|---|---|
IN (subquery) | Принадлежность набору | Короткие и прозрачные наборы | Ловушки с NULL в NOT IN |
EXISTS (subquery) | Факт наличия строки | Проверка связей есть/нет | Нужна аккуратная корреляция |
NOT EXISTS | Отсутствие строки | Безопасный anti-join | Требует понимания связи |
LEFT JOIN ... IS NULL | Отсутствие пары | Альтернатива anti-join | Легко сломать фильтрами в WHERE |
✅ Вывод:
EXISTS/NOT EXISTS чаще всего лучший базовый выбор для semi/anti-join логики.🧠 Must-Know (запомнить)
- Подзапрос в
WHEREбывает некоррелированный и коррелированный. EXISTSпроверяет факт строки, аIN— вхождение в набор.- Для anti-join в production обычно выбирают
NOT EXISTS. NOT INопасен, если подзапрос может вернутьNULL.LEFT JOIN ... IS NULL— рабочая альтернатива anti-join.- Коррелированный подзапрос должен явно ссылаться на внешний алиас.
- На больших данных обязательно проверяйте план выполнения (
EXPLAIN).
✅ Вывод:
Если помнить эти 7 правил, подзапросы становятся предсказуемым и безопасным инструментом.
❌ Частые мифы
❌ Миф:
EXISTS и IN — это всегда одно и то же, можно не думать.
✅ Как правильно: часто результат совпадает, но выбор зависит от семантики и формы запроса.
📎 Почему это важно: неправильный выбор усложняет чтение и может ухудшить производительность.❌ Миф:
NOT IN — нормальный универсальный anti-join.
✅ Как правильно: при NULL в подзапросе NOT IN может дать неожиданный результат; безопаснее NOT EXISTS.
📎 Почему это важно: ошибки здесь тихие и часто попадают в прод без явного падения.❌ Миф: коррелированные подзапросы всегда медленные и их нельзя использовать.
✅ Как правильно: это рабочий паттерн; важны индексы, фильтры и реальный план выполнения.
📎 Почему это важно: запрет полезного инструмента ведёт к сложным и менее читаемым обходам.
❌ Миф: если запрос корректный логически, оптимизация не нужна.
✅ Как правильно: на больших таблицах форма подзапроса и индексы критичны для SLA.
📎 Почему это важно: API-латентность и стоимость инфраструктуры растут именно на таких запросах.
🎤 Часто спрашивают на собеседованиях
❓ Вопрос: В чём разница между
IN и EXISTS?
✅ Ответ: IN проверяет принадлежность набору значений, EXISTS проверяет наличие хотя бы одной связанной строки.❓ Вопрос: Что такое коррелированный подзапрос?
✅ Ответ: это подзапрос, который ссылается на поля внешнего запроса и выполняет проверку для текущей строки.
❓ Вопрос: Почему
NOT IN может быть опасен?
✅ Ответ: если в подзапросе есть NULL, условие может стать UNKNOWN, и результат фильтрации будет неожиданным.❓ Вопрос: Как правильно делать anti-join?
✅ Ответ: обычно через
NOT EXISTS, альтернативно через LEFT JOIN ... IS NULL.❓ Вопрос: Когда
EXISTS особенно уместен?
✅ Ответ: когда нужно проверить факт наличия связанной записи без размножения строк внешней таблицы.❓ Вопрос: Как ускорить подзапросы в
WHERE?
✅ Ответ: индексы по ключам связи и фильтрам, раннее сужение диапазона, и проверка плана через EXPLAIN.🚨 Типичные ошибки
- Использовать
NOT INс подзапросом, не проверив наличиеNULL. - Путать алиасы во внешнем и коррелированном подзапросе.
- Выбирать
IN, когда нужна семантика «есть хотя бы одна строка». - Прятать сложную бизнес-логику в нечитабельные вложенные подзапросы.
- Ставить функции/касты на индексируемые колонки в условиях сравнения.
- Игнорировать
EXPLAINдля тяжёлых фильтров с подзапросами. - Смешивать anti-join и обычный
LEFT JOINбез явной цели запроса.
✅ Вывод:
Большинство ошибок здесь предсказуемы и устраняются правильным выбором конструкции.
✅ Best Practices
- Начинайте с формулировки задачи:
exists,in setилиnot exists. - Для semi-join чаще используйте
EXISTS. - Для anti-join по умолчанию используйте
NOT EXISTS. - Если используете
NOT IN, гарантируйте отсутствиеNULLв подзапросе. - Проверяйте, что корреляция в подзапросе ссылается на правильный внешний алиас.
- На больших таблицах проверяйте план и индексы перед релизом.
- Держите подзапросы читаемыми: понятные алиасы и короткие условия.
✅ Вывод:
Хороший SQL с подзапросами — это баланс семантики, читаемости и производительности.
🏁 Заключение
Подзапросы в
Они помогают точно описывать бизнес-правила без лишних соединений и дублей.
WHERE, EXISTS, IN и anti-join — центральные инструменты сложной фильтрации.Они помогают точно описывать бизнес-правила без лишних соединений и дублей.
Если вы контролируете
запросы становятся надёжными и предсказуемыми.
NULL-поведение, понимаете разницу EXISTS vs IN и уверенно используете NOT EXISTS,запросы становятся надёжными и предсказуемыми.
✅ Вывод:
Уровень владения этой темой напрямую влияет на качество production-фильтров и бизнес-метрик.