SQL

БЛОК 4. Сложные запросы — 13. Подзапросы + EXISTS + IN

📚 25 вопросовПройти тест →
Лекция

БЛОК 4. Сложные запросы — 13. Подзапросы + EXISTS + IN

SQL

13. Подзапросы + EXISTS + IN

🧭 Введение: когда одного JOIN уже мало

В реальных запросах нужно не только соединять таблицы, но и проверять условия вида:
«есть ли связанные записи», «входит ли значение в набор», «нет ли запрещённых строк».
Для этого используются подзапросы в WHERE, коррелированные подзапросы, EXISTS, IN и anti-join-паттерны.
Именно здесь чаще всего появляются логические баги: дубли, неверные фильтры и ловушки с NULL.
💡 Совет: Перед написанием подзапроса формулируйте вопрос в одном предложении:
«я проверяю существование строки», «я сравниваю с набором значений» или «я исключаю связанные записи».
Вывод: Подзапросы и EXISTS/IN нужны не для «сложности SQL», а для точной бизнес-логики фильтрации.

⚠️ Проблема -> решение

Типичная проблема:
  1. разработчик пишет IN/NOT IN «по привычке»,
  2. не учитывает NULL и кардинальность,
  3. получает тихо неверный результат в отчёте.
Последствия:
  1. ошибочные сегменты пользователей,
  2. неверные бизнес-метрики,
  3. тяжёлые запросы из-за неудачной формы подзапроса.
Решение:
  1. выбирать конструкцию по смыслу (EXISTS, IN, NOT EXISTS),
  2. явно учитывать поведение NULL,
  3. проверять план выполнения и переписывать подзапрос при необходимости.
🟢 Если совсем просто: Сначала выбираем правильную логику (проверка существования или принадлежность набору), потом оптимизируем.
🎯 Как понять, что этап прошёл успешно: Вы можете объяснить, почему выбрали именно 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», фильтр 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-фильтров и бизнес-метрик.
🎯

Проверьте знания

Закрепите материал — пройдите тест по теме «БЛОК 4. Сложные запросы — 13. Подзапросы + EXISTS + IN»

Пройти тест →