
EXISTS и NOT EXISTS: как найти клиентов с заказами и без них без дублей


Когда нужен не список заказов, а два списка клиентов — у кого заказы есть и у кого их нет, — обычный JOIN часто уводит в сторону. Он соединяет строки, а значит, клиент с двумя заказами появится дважды, с тремя — трижды. Потом запрос начинают «лечить» через DISTINCT, но это уже исправление побочного эффекта, а не точное выражение задачи.
Для такой проверки удобнее мыслить не «соединить таблицы», а «проверить, существует ли хотя бы одна связанная строка». Именно это делают EXISTS и NOT EXISTS. Ниже разберем один узкий вопрос: как получить два чистых списка клиентов без размножения строк и как быстро проверить, что результат совпадает с ожиданием.
Почему эта задача не сводится к обычному JOIN
JOIN нужен, когда вы хотите взять данные сразу из двух таблиц: например, клиента и его даты заказов. Но если вопрос звучит как «есть ли у клиента хотя бы один заказ», то каждая дополнительная строка заказа уже не несет новой пользы. Вам не нужны все совпадения, вам нужен только факт наличия хотя бы одного совпадения.
Из-за этого INNER JOIN дает лишние строки. Запрос ниже вернет по одной строке на каждый заказ, а не по одной строке на клиента:
SELECT c.client_id, c.client_name
FROM clients c
JOIN orders o ON o.client_id = c.client_id;
Если у клиента три заказа, он появится три раза. DISTINCT может убрать повторы в результате, но он не меняет смысл уже выполненного соединения. Для учебной задачи это важное различие: EXISTS выражает намерение проверить наличие записи, а JOIN выражает намерение получить пары совпавших строк.
Схема клиентов и заказов
Учебный пример. Среда выполнения: PostgreSQL. Возьмем две таблицы: список клиентов и список заказов. В данных специально есть клиенты с несколькими заказами, чтобы было видно, где JOIN раздувает результат.
WITH clients AS (
SELECT *
FROM (VALUES
(1, 'Анна'),
(2, 'Борис'),
(3, 'Вера'),
(4, 'Глеб'),
(5, 'Дина')
) AS t(client_id, client_name)
),
orders AS (
SELECT *
FROM (VALUES
(101, 1, DATE '2024-05-10'),
(102, 1, DATE '2024-06-02'),
(103, 3, DATE '2024-06-15'),
(104, 3, DATE '2024-06-20'),
(105, 5, DATE '2024-07-01')
) AS t(order_id, client_id, order_date)
)
SELECT *
FROM clients;
По этим данным ожидаемый ответ легко проверить вручную. У Анны есть два заказа, у Веры — два, у Дины — один. У Бориса и Глеба заказов нет. Значит, итог должен быть таким:
| Клиент | Есть заказы |
|---|---|
| Анна | Да |
| Борис | Нет |
| Вера | Да |
| Глеб | Нет |
| Дина | Да |
Это и будет наш контрольный ориентир: список клиентов с заказами должен содержать Анну, Веру и Дину ровно по одному разу. Список без заказов — Бориса и Глеба.
Как получить клиентов с заказами через EXISTS
Теперь формулируем задачу буквально: для каждой строки из clients нужно проверить, существует ли хотя бы одна строка в orders с тем же client_id. Такой запрос называют коррелированным подзапросом, потому что внутренняя проверка ссылается на текущую строку внешнего запроса.
WITH clients AS (
SELECT *
FROM (VALUES
(1, 'Анна'),
(2, 'Борис'),
(3, 'Вера'),
(4, 'Глеб'),
(5, 'Дина')
) AS t(client_id, client_name)
),
orders AS (
SELECT *
FROM (VALUES
(101, 1, DATE '2024-05-10'),
(102, 1, DATE '2024-06-02'),
(103, 3, DATE '2024-06-15'),
(104, 3, DATE '2024-06-20'),
(105, 5, DATE '2024-07-01')
) AS t(order_id, client_id, order_date)
)
SELECT c.client_id, c.client_name
FROM clients c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.client_id = c.client_id
)
ORDER BY c.client_id;
Результат будет таким: 1 Анна, 3 Вера, 5 Дина. Важно, что каждая строка внешней таблицы либо проходит фильтр, либо нет. Даже если у Анны два заказа, строка Анны во внешнем SELECT одна, поэтому и в выдаче она будет одна. В документации PostgreSQL как раз показан этот эффект: запрос с EXISTS похож на inner join по смыслу совпадения, но не размножает строки внешней таблицы по числу найденных совпадений.
Почему внутри подзапроса часто пишут SELECT 1, а не SELECT o.order_id? Потому что для EXISTS важен не набор возвращаемых значений, а сам факт того, что подзапрос вернул хотя бы одну строку. По той же причине такой подзапрос обычно выполняется лишь до момента, когда наличие строки уже установлено. Это полезно понимать, но не стоит превращать в обещание, что EXISTS всегда будет быстрее любого JOIN: производительность зависит и от данных, и от плана выполнения.
Как получить клиентов без заказов через NOT EXISTS
Вторая половина задачи строится симметрично. Если EXISTS отвечает на вопрос «нашлась ли связанная строка», то NOT EXISTS отвечает на вопрос «не нашлось ли ни одной связанной строки». Для списка клиентов без заказов это почти дословная формулировка задачи.
WITH clients AS (
SELECT *
FROM (VALUES
(1, 'Анна'),
(2, 'Борис'),
(3, 'Вера'),
(4, 'Глеб'),
(5, 'Дина')
) AS t(client_id, client_name)
),
orders AS (
SELECT *
FROM (VALUES
(101, 1, DATE '2024-05-10'),
(102, 1, DATE '2024-06-02'),
(103, 3, DATE '2024-06-15'),
(104, 3, DATE '2024-06-20'),
(105, 5, DATE '2024-07-01')
) AS t(order_id, client_id, order_date)
)
SELECT c.client_id, c.client_name
FROM clients c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.client_id = c.client_id
)
ORDER BY c.client_id;
Здесь результатом будут 2 Борис и 4 Глеб. Такой запрос особенно удобен тем, что он прямо отражает смысл проверки отсутствия. Вам не нужно строить LEFT JOIN и потом отдельно проверять, какие столбцы оказались NULL. Такой вариант тоже бывает полезен, но он решает задачу через соединение, а не через проверку существования.
Отдельно стоит помнить о типичной ловушке с NOT IN. В документации PostgreSQL отмечено, что если в результате подзапроса для NOT IN встречается NULL, итог может стать не true, а null, и строка не пройдет фильтр. Для задач вида «нет связанной записи» NOT EXISTS обычно надежнее и понятнее, потому что он проверяет сам факт нахождения строки, а не сравнивает значение со списком, в котором могут оказаться пустые значения.
Как сверить результат с контрольным списком
После написания запроса полезно не ограничиваться фразой «похоже, работает». Удобнее завести маленький контрольный список с ожидаемым ответом и сравнить ожидание с фактом. Это особенно помогает, когда позже в подзапрос добавляются условия по дате, статусу или типу заказа.
WITH clients AS (
SELECT *
FROM (VALUES
(1, 'Анна'),
(2, 'Борис'),
(3, 'Вера'),
(4, 'Глеб'),
(5, 'Дина')
) AS t(client_id, client_name)
),
orders AS (
SELECT *
FROM (VALUES
(101, 1, DATE '2024-05-10'),
(102, 1, DATE '2024-06-02'),
(103, 3, DATE '2024-06-15'),
(104, 3, DATE '2024-06-20'),
(105, 5, DATE '2024-07-01')
) AS t(order_id, client_id, order_date)
),
control_list AS (
SELECT *
FROM (VALUES
(1, true),
(2, false),
(3, true),
(4, false),
(5, true)
) AS t(client_id, expected_has_orders)
)
SELECT
cl.client_id,
c.client_name,
cl.expected_has_orders,
EXISTS (
SELECT 1
FROM orders o
WHERE o.client_id = cl.client_id
) AS actual_has_orders
FROM control_list cl
JOIN clients c ON c.client_id = cl.client_id
WHERE cl.expected_has_orders <> EXISTS (
SELECT 1
FROM orders o
WHERE o.client_id = cl.client_id
)
ORDER BY cl.client_id;
Если запрос не вернул ни одной строки, сверка пройдена: ожидание совпало с вычисленным результатом для всех пяти клиентов. Это уже проверяемый итог, а не интуитивная уверенность. В нашем учебном примере также легко проверить арифметику списков: клиентов всего 5, с заказами 3, без заказов 2, и 3 + 2 = 5 без пересечений.
Что чаще всего ломает такую задачу:
- использование JOIN вместо проверки существования, когда нужен только факт наличия;
- добавление фильтра по заказам не внутрь EXISTS, а во внешний WHERE, из-за чего меняется смысл проверки;
- замена NOT EXISTS на NOT IN без учета возможных NULL в подзапросе.
Практический вывод простой: если вы хотите получить два чистых списка клиентов, думайте от внешней таблицы clients и задавайте вопрос к каждой ее строке: есть ли хотя бы один связанный заказ или нет. Тогда запрос остается коротким, не плодит дубли и легче проверяется контрольным списком.
Где это отработать дальше
Во внутренней карточке каталога курса SQL от Prime Soft подтверждены отдельные темы по JOIN, работе с дублями и DISTINCT, подзапросам в WHERE с IN и EXISTS, а также по плану выполнения запросов. Это полезная связка именно для задач, где нужно выбрать между соединением таблиц и проверкой факта существования записи.





















