EXISTS — один из самых важных операторов SQL, который часто недооценивают. Правильное использование EXISTS вместо IN или JOIN может кардинально ускорить запрос. Разберём всё от основ до нюансов производительности.
Что делает EXISTS
EXISTS проверяет: возвращает ли подзапрос хотя бы одну строку.
-- Пользователи, у которых есть хотя бы один заказ
SELECT u.id, u.name
FROM users u
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.id
);
Подзапрос внутри EXISTS — коррелированный: для каждой строки из внешнего запроса он выполняется заново с новым значением u.id.
SELECT 1 или SELECT * — какая разница?
Внутри EXISTS можно писать что угодно — PostgreSQL не читает данные, только проверяет существование:
-- Все три варианта эквивалентны и одинаково быстры:
WHERE EXISTS (SELECT 1 FROM orders WHERE user_id = u.id)
WHERE EXISTS (SELECT * FROM orders WHERE user_id = u.id)
WHERE EXISTS (SELECT id FROM orders WHERE user_id = u.id)
Соглашение: пишите SELECT 1 — это явно показывает намерение.
NOT EXISTS — нет соответствующих строк
-- Пользователи, которые НИКОГДА не делали заказ
SELECT u.id, u.name
FROM users u
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.id
);
EXISTS vs IN: в чём разница
Оба ищут совпадения, но работают по-разному.
IN с подзапросом
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders);
IN загружает весь результат подзапроса в память, затем проверяет каждое значение.
EXISTS
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
EXISTS останавливается при первом совпадении — не читает лишние строки.
Когда важна разница
EXISTS быстрее когда:
- Подзапрос возвращает много строк
- Ранние совпадения позволяют выйти досрочно
- Есть индекс на поле в подзапросе
IN быстрее когда:
- Подзапрос возвращает мало строк (сотни)
- Субзапрос выполняется один раз и хешируется
На современных PostgreSQL (12+) оптимизатор часто сам конвертирует одно в другое. Но всё же знать разницу важно — не всегда оптимизатор справляется.
NOT EXISTS vs NOT IN: КРИТИЧЕСКОЕ отличие
Здесь всё меняется из-за NULL.
-- NOT IN с NULL в подзапросе — ловушка!
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders);
-- Если хоть один user_id = NULL → результат ПУСТОЙ!
Почему? Потому что id NOT IN (..., NULL) = id <> 1 AND id <> 2 AND ... AND id <> NULL. А любое сравнение с NULL даёт NULL (не TRUE) → условие никогда не выполняется.
-- NOT EXISTS корректно обрабатывает NULL
SELECT * FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Работает правильно, даже если user_id может быть NULL
Правило: всегда используйте NOT EXISTS вместо NOT IN, если подзапрос может содержать NULL.
NOT EXISTS vs LEFT JOIN IS NULL
Ещё один способ найти «отсутствующие» записи:
-- Через LEFT JOIN
SELECT u.id, u.name
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL;
-- Через NOT EXISTS
SELECT u.id, u.name
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
Оба варианта корректны и обычно одинаково быстры. Современный PostgreSQL генерирует идентичные планы.
Предпочтение по стилю:
NOT EXISTS— намерение читается явно: «нет заказов»LEFT JOIN IS NULL— иногда нужен, если нужно выбрать поля из правой таблицы (но тут их нет)
Практические примеры
Клиенты без активной подписки
SELECT u.email, u.name
FROM users u
WHERE NOT EXISTS (
SELECT 1
FROM subscriptions s
WHERE s.user_id = u.id
AND s.is_active = true
);
Товары, которые ни разу не покупали
SELECT p.id, p.name
FROM products p
WHERE NOT EXISTS (
SELECT 1
FROM order_items oi
WHERE oi.product_id = p.id
);
Дни без транзакций за последний месяц
SELECT d::date AS day_without_transactions
FROM generate_series(
NOW() - INTERVAL '30 days',
NOW(),
INTERVAL '1 day'
) AS d
WHERE NOT EXISTS (
SELECT 1
FROM transactions t
WHERE t.created_at::date = d::date
);
Найти дубликаты и оставить первый
-- Удалить дубликаты email, оставить строку с минимальным id
DELETE FROM users
WHERE NOT EXISTS (
SELECT 1
FROM (
SELECT MIN(id) AS min_id
FROM users
GROUP BY email
) sub
WHERE sub.min_id = users.id
);
Оптимизация: индексы
EXISTS использует индекс на поле в условии подзапроса:
-- Убедитесь, что есть индекс:
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- Теперь EXISTS будет быстрым:
WHERE EXISTS (SELECT 1 FROM orders WHERE user_id = u.id)
-- PostgreSQL: Index Scan on orders, одно обращение к индексу
Без индекса EXISTS выполнит Seq Scan для каждой строки внешнего запроса — O(n×m).
EXISTS в UPDATE и DELETE
-- Обновить пользователей, сделавших хотя бы один заказ
UPDATE users
SET is_buyer = true
WHERE EXISTS (
SELECT 1 FROM orders WHERE user_id = users.id
);
-- Удалить товары, которые не продавались
DELETE FROM products
WHERE NOT EXISTS (
SELECT 1 FROM order_items WHERE product_id = products.id
);
Итог: когда что использовать
| Задача | Рекомендация |
|---|---|
| Проверить существование | EXISTS |
| «Отсутствующие» записи | NOT EXISTS |
| IN с коротким списком констант | IN (1, 2, 3) — нормально |
| NOT IN с подзапросом | ❌ Опасно! Используйте NOT EXISTS |
| Нужны данные из связанной таблицы | JOIN |