Коррелированный подзапрос — это подзапрос, который ссылается на колонку из внешнего запроса и поэтому выполняется заново для каждой его строки. Обычный подзапрос считается один раз и подставляет результат как константу; коррелированный привязан к текущей строке снаружи и без неё вообще не имеет смысла. Отсюда и его сила (можно сравнивать строку с её же «соседями»), и его главная беда — на больших таблицах он легко превращается в скрытый цикл на миллионы итераций.
Я много лет пишу SQL для аналитики и вижу, как коллеги спотыкаются именно здесь: код выглядит понятно, отдаёт правильный результат на тестовой выборке, а в проде запрос висит минуту. Давай разберём, где эта штука уместна, а где её пора переписать.
Чем коррелированный подзапрос отличается от обычного?
Обычный (некоррелированный) подзапрос — это самостоятельный запрос. Его можно скопировать, выполнить отдельно, и он вернёт результат. База считает его один раз, кэширует значение и дальше работает с ним как с числом.
-- Обычный подзапрос: средняя зарплата по ВСЕЙ компании считается один раз
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
Тут SELECT AVG(salary) FROM employees не зависит ни от чего снаружи. Выполни его отдельно — получишь, скажем, 85 000. Дальше внешний запрос просто сравнивает каждую зарплату с этим числом.
Коррелированный подзапрос так вырвать нельзя. Он ссылается на алиас внешней таблицы:
-- Коррелированный: средняя считается ЗАНОВО для отдела каждого сотрудника
SELECT e.name, e.department_id, e.salary
FROM employees e
WHERE e.salary > (
SELECT AVG(e2.salary)
FROM employees e2
WHERE e2.department_id = e.department_id -- вот эта строка делает подзапрос коррелированным
);
Строка e2.department_id = e.department_id тянет e.department_id из внешнего запроса. Скопируй этот подзапрос отдельно — база скажет, что не знает такого e. Логически он выполняется так: берём первого сотрудника, узнаём его отдел, считаем среднее по этому отделу, сравниваем; берём второго — считаем среднее заново; и так по кругу для каждой строки. Отсюда и название: подзапрос коррелирует (связан) с внешним запросом.
Как найти зарплату выше средней по отделу?
Это классика собеседований и первый пример, на котором коррелированный подзапрос показывает себя во всей красе. Задача: вывести сотрудников, которые получают больше, чем в среднем по их собственному отделу.
SELECT e.name, e.department_id, e.salary
FROM employees e
WHERE e.salary > (
SELECT AVG(e2.salary)
FROM employees e2
WHERE e2.department_id = e.department_id
);
Обрати внимание: обычным подзапросом это не решить в одну строку, потому что для каждого сотрудника «среднее» — своё, оно зависит от его отдела. Именно связь с внешней строкой позволяет сравнивать человека не со всей компанией, а с его конкретной командой.
Тот же приём работает для «свежее последнего события», «дороже медианы по категории», «раньше первого платежа пользователя». Везде, где нужно сравнить строку с агрегатом её же группы, коррелированный подзапрос читается интуитивно. Прогнать похожую задачу на реальных данных удобно в SQL-тренажёре — там сразу видно план и время выполнения.
Как проверить наличие хотя бы одного заказа через EXISTS?
Второй сценарий, где коррелированный подзапрос почти незаменим по читаемости, — проверка «а есть ли вообще хоть одна связанная строка». Для этого есть EXISTS.
-- Пользователи, у которых есть хотя бы один заказ
SELECT u.id, u.email
FROM users u
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.id
);
EXISTS возвращает TRUE, как только находит первую подходящую строку, и на этом останавливается — ему не важно, сколько там заказов, один или тысяча. Внутри принято писать SELECT 1, потому что конкретные колонки не нужны, важен сам факт наличия строки.
Зеркальный случай — найти пользователей без единого заказа. Тут NOT EXISTS:
-- Зарегистрировались, но ни разу не заказали
SELECT u.id, u.email
FROM users u
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.id
);
Это частый кусок аналитики: посчитать «спящих» пользователей, отвалившихся после регистрации, аккаунты без платежей. Логика «есть связь / нет связи» напрямую ложится на EXISTS / NOT EXISTS, и читается такой запрос почти как русский текст. Похожие разборы воронки и активации я собрал в практических кейсах, а базовые метрики вроде DAU как раз строятся на таких проверках наличия событий.
Почему коррелированный подзапрос бывает медленным?
Потому что в наивном исполнении это вложенный цикл. Внешний запрос перебирает строки, и для каждой запускает подзапрос заново. Если во внешней таблице миллион строк, а подзапрос каждый раз сканирует таблицу заказов — ты получаешь миллион сканов. Формально сложность превращается в «строки снаружи умножить на стоимость подзапроса», и это растёт очень быстро.
Практический маркер: запрос отлично работает на тестовой базе в тысячу строк и намертво встаёт на проде. На малой выборке миллион итераций проходит за миллисекунды, а на реальных данных это уже часы работы CPU.
Три вещи, которые чаще всего спасают:
- Индекс на колонке связи. Если в подзапросе есть
WHERE o.user_id = u.id, индекс поorders(user_id)превращает полный скан в точечный поиск. Часто одного этого достаточно, чтобы «медленный» запрос стал мгновенным. - **
EXISTSвместоCOUNT(*) > 0.** Не пиши(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) > 0— это заставляет базу пересчитать все заказы.EXISTSостанавливается на первой найденной строке. EXISTSвместоINс большим списком. На больших наборахIN (подзапрос)ведёт себя хуже, чем полу-соединение черезEXISTS.
Важная оговорка: современные оптимизаторы (PostgreSQL, например) часто сами превращают коррелированный подзапрос в полу-соединение и выполняют его эффективно. Так что «коррелированный = медленный» — это не закон, а повод посмотреть в EXPLAIN. Иногда переписывать нечего, планировщик уже всё сделал за тебя.
Как переписать коррелированный подзапрос на JOIN?
Когда план действительно плохой, первый инструмент — переписать логику на соединение. Пример с наличием заказов превращается в обычный JOIN:
-- Было: WHERE EXISTS (...)
-- Стало: полу-соединение через JOIN + дедупликация
SELECT u.id, u.email
FROM users u
JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.email;
GROUP BY (или DISTINCT) тут нужен, потому что JOIN размножит пользователя по числу его заказов, а нам нужен каждый один раз. Это отличие важно помнить: EXISTS не создаёт дубликатов, а JOIN — создаёт, и их приходится схлопывать.
Случай «пользователи без заказов» переписывается через LEFT JOIN с проверкой на NULL — это стандартный антипаттерн-в-хорошем-смысле, который спрашивают почти на каждом собеседовании:
SELECT u.id, u.email
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.user_id IS NULL; -- заказ не нашёлся → строка справа пустая
LEFT JOIN подтягивает заказы, а если их нет — правая часть заполняется NULL. Фильтр WHERE o.user_id IS NULL оставляет ровно тех, у кого связи не нашлось. Разобрать разницу между NOT EXISTS, NOT IN и LEFT JOIN ... IS NULL (особенно как NOT IN ломается на NULL) стоит отдельно — это любимая ловушка интервьюеров, её удобно потренировать на задачах по SQL и в подборке вопросов с собеседований.
Когда лучше использовать оконную функцию вместо подзапроса?
Как только тебе нужен агрегат по группе, но при этом хочется оставить все строки — задумайся об оконной функции. Вернёмся к зарплате выше средней по отделу. Коррелированный подзапрос пересчитывает среднее для каждого сотрудника. Оконная функция считает среднее по каждому отделу один раз и «раздаёт» его всем строкам отдела:
SELECT name, department_id, salary
FROM (
SELECT name,
department_id,
salary,
AVG(salary) OVER (PARTITION BY department_id) AS dept_avg
FROM employees
) t
WHERE salary > dept_avg;
AVG(salary) OVER (PARTITION BY department_id) — это и есть «среднее по отделу текущей строки», но посчитанное за один проход по таблице, а не N раз. На больших данных разница в скорости бывает драматической. Плюс окно легко расширить: добавить RANK(), чтобы взять топ-3 по зарплате в отделе, или SUM(...) OVER (...) для нарастающего итога — коррелированным подзапросом такое пишется мучительно.
Ещё частый кейс — «последний заказ каждого пользователя». Через коррелированный подзапрос в SELECT:
SELECT u.id,
(SELECT MAX(o.created_at)
FROM orders o
WHERE o.user_id = u.id) AS last_order_at
FROM users u;
Через окно — за один проход и с возможностью вытащить не только дату, но и любые поля этого заказа:
SELECT user_id, created_at AS last_order_at, amount
FROM (
SELECT user_id, created_at, amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders
) t
WHERE rn = 1;
Оконные функции — тот навык, который отделяет джуна от мидла в аналитике. Системный разбор от PARTITION BY до рамок окна есть в курсе по SQL, а короткие формулировки синтаксиса удобно подсматривать в SQL-справочнике.
Нужно ли вообще избегать коррелированных подзапросов?
Нет. Это не «плохой SQL», которого надо стыдиться. У него есть законные ниши, где он читается лучше любой альтернативы:
EXISTS/NOT EXISTSдля проверки наличия связи — почти всегда оставляй как есть. Оптимизатор превращает это в полу-соединение сам, и код понятнее, чемJOIN + GROUP BY.- Разовые запросы на небольших таблицах — если строк тысячи, а не миллионы, читаемость важнее микросекунд. Не усложняй ради экономии, которой никто не заметит.
- Быстрый черновик логики — коррелированный подзапрос часто пишется первым, потому что мысль «для каждой строки сделай вот это» — самая естественная. Уже потом, если
EXPLAINпоказал проблему, переписываешь.
Мой рабочий порядок такой: пишу как думаю (часто это коррелированный подзапрос), проверяю результат, а дальше смотрю план. Если запрос в пайплайне и таблицы большие — переписываю на JOIN или окно. Если это разовая выгрузка — оставляю. Правило «сначала правильно, потом быстро» тут работает буквально.
Что спрашивают на собеседовании и что тренировать?
Три вещи всплывают почти в каждом интервью на аналитика. Первое — объяснить своими словами разницу между обычным и коррелированным подзапросом (ключевое: коррелированный ссылается на внешнюю строку и выполняется для каждой). Второе — написать «зарплату выше средней по отделу» и следом переписать её на оконную функцию. Третье — найти строки без пары (NOT EXISTS против LEFT JOIN ... IS NULL против NOT IN, и почему последний коварен с NULL).
Если хочешь довести это до автоматизма, а не просто «понимать в теории»:
- решай задачи в SQL-тренажёре с настоящим PostgreSQL в браузере и смотри планы через
EXPLAIN; - прогоняй реальные вопросы с собеседований на время;
- те же выборки без базы попробуй воспроизвести на pandas в Python-тренажёре — полезно понять, что
EXISTS— это по сутиisin, а коррелированный подзапрос —groupby().transform().
Коррелированный подзапрос — не магия и не зло. Это способ сказать «для каждой строки посмотри на её окружение». Понимаешь, когда он читается лучше, а когда пора звать JOIN или окно, — и половина каверзных SQL-вопросов на собеседовании перестаёт быть каверзными.
Хочешь разобрать оконные функции, воронки и когорты системно, с проверкой решений и разбором типичных ошибок — это открывается в Pro вместе со всеми SQL- и Python-задачами и безлимитным AI-собесом. Начать можно с бесплатных задач, а дальше решить, нужен ли полный доступ.