Dieser Artikel ist derzeit auf Russisch — die englische Übersetzung ist in Arbeit.
В SQL есть операции, которые помогают работать с результатами запросов как с наборами строк.
Например:
UNION
объединяет выборки.
INTERSECT
оставляет только строки, которые есть в обеих выборках.
А:
EXCEPT
делает обратную задачу: показывает строки, которые есть в первой выборке, но которых нет во второй.
То есть EXCEPT отвечает на вопрос:
Что есть здесь, но отсутствует там?
Это очень полезно в реальных задачах:
- найти пользователей, которые зарегистрировались, но не сделали заказ;
- найти товары, которые были в старом каталоге, но пропали из нового;
- найти строки, которые есть в источнике, но не доехали в целевую таблицу;
- найти клиентов, которых нет в списке заблокированных;
- сравнить две версии таблицы после миграции.
Если INTERSECT — это «общее», то EXCEPT — это «разница».
Что делает EXCEPT простыми словами
Представим два списка.
Первый список — все пользователи:
user_id
-------
1
2
3
4
5
Второй список — заблокированные пользователи:
user_id
-------
2
4
Если мы хотим получить всех пользователей, кроме заблокированных, нам нужно из первого списка вычесть второй.
Останется:
user_id
-------
1
3
5
В SQL это можно записать через EXCEPT:
SELECT user_id
FROM users
EXCEPT
SELECT user_id
FROM banned_users;
Смысл такой:
Возьми строки из первого SELECT и убери из них строки, которые нашлись во втором SELECT.
Базовый синтаксис EXCEPT
Общий вид запроса:
SELECT column_1, column_2
FROM first_table
EXCEPT
SELECT column_1, column_2
FROM second_table;
Первый SELECT — это основной набор.
Второй SELECT — это набор, который нужно вычесть.
Например:
SELECT email
FROM users
EXCEPT
SELECT email
FROM unsubscribed_users;
Такой запрос вернёт email пользователей, которые есть в users, но отсутствуют в unsubscribed_users.
То есть это пользователи, которые не отписались.
Пример: пользователи без заказов
Допустим, есть таблица users:
id | email
---+----------------
1 | anna@mail.com
2 | bob@mail.com
3 | kate@mail.com
4 | tom@mail.com
И таблица orders:
id | user_id | amount
---+---------+--------
1 | 1 | 1500
2 | 3 | 2300
Нужно найти пользователей, у которых нет заказов.
Первый набор — все пользователи:
SELECT id
FROM users;
Результат:
id
--
1
2
3
4
Второй набор — пользователи, у которых есть заказы:
SELECT user_id
FROM orders;
Результат:
user_id
-------
1
3
Теперь вычитаем второй набор из первого:
SELECT id
FROM users
EXCEPT
SELECT user_id
FROM orders;
Результат:
id
--
2
4
Пользователи 2 и 4 есть в таблице users, но их нет среди user_id в заказах.
Значит, они зарегистрировались, но ещё ничего не заказали.
EXCEPT — это не JOIN
Новички иногда пытаются решить все задачи через JOIN, но EXCEPT работает по другой логике.
JOIN соединяет строки из разных таблиц.
А EXCEPT сравнивает два готовых результата SELECT и вычитает один из другого.
Например:
SELECT id
FROM users
EXCEPT
SELECT user_id
FROM orders;
Этот запрос не соединяет таблицы.
Он просто берёт один список значений и вычитает из него другой список.
Можно запомнить так:
JOIN — соединить таблицы
EXCEPT — вычесть одну выборку из другой
Если вопрос звучит как «каких строк из первой выборки нет во второй», часто стоит подумать именно про EXCEPT.
EXCEPT сравнивает строки целиком
Очень важный момент: EXCEPT сравнивает всю строку целиком, а не только первую колонку.
Например:
SELECT 'anna@mail.com' AS email, 'Vietnam' AS country
EXCEPT
SELECT 'anna@mail.com' AS email, 'Germany' AS country;
Результат будет:
email | country
---------------+---------
anna@mail.com | Vietnam
Почему строка осталась?
Потому что полностью она не совпала со строкой справа.
Слева:
anna@mail.com | Vietnam
Справа:
anna@mail.com | Germany
Email одинаковый, но страна разная. Для EXCEPT это разные строки.
А вот такой запрос вернёт пустой результат:
SELECT 'anna@mail.com' AS email, 'Vietnam' AS country
EXCEPT
SELECT 'anna@mail.com' AS email, 'Vietnam' AS country;
Потому что строка справа полностью совпала со строкой слева и вычла её.
Главное правило:
EXCEPT сравнивает все выбранные колонки.
Если вы хотите вычитать только по email, выбирайте только email:
SELECT email
FROM users
EXCEPT
SELECT email
FROM unsubscribed_users;
Если добавите country, created_at, source или другие колонки, они тоже будут участвовать в сравнении.
Требования к SELECT внутри EXCEPT
У двух запросов должно совпадать количество колонок.
Так можно:
SELECT id, email
FROM users
EXCEPT
SELECT id, email
FROM archived_users;
В обоих запросах по две колонки.
А так нельзя:
SELECT id, email
FROM users
EXCEPT
SELECT id, email, country
FROM archived_users;
Первый запрос возвращает две колонки, а второй — три. База не сможет сравнить такие строки.
Также важен порядок колонок.
Например:
SELECT id, email
FROM users
EXCEPT
SELECT email, id
FROM archived_users;
Даже если запрос технически выполнится, смысл будет неправильным: база будет сравнивать id с email, а email с id.
Поэтому при EXCEPT важно следить за тремя вещами:
- количество колонок одинаковое;
- типы колонок совместимы;
- порядок колонок соответствует смыслу.
Имена колонок берутся из первого SELECT
Названия колонок в итоговом результате берутся из первого SELECT.
Например:
SELECT id, email
FROM users
EXCEPT
SELECT user_id, contact_email
FROM old_users;
Итоговые колонки будут называться:
id | email
Даже если во втором запросе они называются user_id и contact_email.
Поэтому первый SELECT лучше оформлять аккуратно:
SELECT
id AS user_id,
email AS user_email
FROM users
EXCEPT
SELECT
user_id,
contact_email
FROM old_users;
Так результат будет понятнее.
EXCEPT убирает дубликаты
Обычный EXCEPT работает как операция над множествами. А в множестве одинаковые строки хранятся один раз.
Например:
SELECT 'SQL' AS course
EXCEPT
SELECT 'Python' AS course;
Результат:
course
------
SQL
А если слева строка повторяется несколько раз, обычный EXCEPT всё равно вернёт её максимум один раз.
Представим левую выборку:
user_id
-------
1
1
1
2
3
Правая выборка:
user_id
-------
2
Обычный EXCEPT вернёт:
user_id
-------
1
3
Пользователь 1 был слева три раза, но в результате появился один раз.
Потому что EXCEPT по умолчанию убирает дубликаты.
EXCEPT ALL: разность с учётом повторов
У EXCEPT есть вариант:
EXCEPT ALL
Он учитывает количество повторов.
Логика такая:
сколько раз строка встретилась слева, минус сколько раз она встретилась справа.
Например, слева:
user_id
-------
1
1
1
2
3
Справа:
user_id
-------
1
2
Для user_id = 1:
слева — 3 раза
справа — 1 раз
остаётся 2 раза
Для user_id = 2:
слева — 1 раз
справа — 1 раз
остаётся 0 раз
Для user_id = 3:
слева — 1 раз
справа — 0 раз
остаётся 1 раз
Результат EXCEPT ALL:
user_id
-------
1
1
3
Пример запроса:
SELECT user_id
FROM orders
EXCEPT ALL
SELECT user_id
FROM refunded_orders;
Обычный EXCEPT вернул бы уникальные значения.
EXCEPT ALL сохраняет кратность, то есть количество повторов.
На практике обычный EXCEPT используется чаще, потому что во многих задачах важен сам факт отсутствия строки во второй выборке. Но если вы сравниваете фактическое количество строк или ищете расхождения в дублях, EXCEPT ALL может быть полезен.
Пример: товары, которые пропали из нового каталога
Допустим, есть старый каталог товаров:
sku
-------
A-100
A-200
A-300
A-400
И новый каталог:
sku
-------
A-100
A-300
A-500
Нужно найти товары, которые были в старом каталоге, но отсутствуют в новом.
SELECT sku
FROM old_catalog
EXCEPT
SELECT sku
FROM new_catalog;
Результат:
sku
-------
A-200
A-400
Это классический сценарий для EXCEPT.
Мы как будто спрашиваем:
Что было раньше, но исчезло сейчас?
Пример: клиенты, которые не отписались
Допустим, есть таблица users:
email
----------------
anna@mail.com
bob@mail.com
kate@mail.com
tom@mail.com
И таблица unsubscribed_users:
email
----------------
bob@mail.com
tom@mail.com
Нужно получить email пользователей, которым можно отправить рассылку.
SELECT email
FROM users
EXCEPT
SELECT email
FROM unsubscribed_users;
Результат:
email
--------------
anna@mail.com
kate@mail.com
Такой запрос коротко выражает бизнес-логику:
все пользователи минус те, кто отписался.
EXCEPT и NULL
В обычных сравнениях SQL NULL ведёт себя необычно.
Например:
SELECT NULL = NULL;
не возвращает TRUE.
Но в операциях над множествами, таких как EXCEPT, строки с NULL в одинаковых позициях считаются совпадающими.
Пример:
SELECT NULL AS country
EXCEPT
SELECT NULL AS country;
Результат будет пустым.
Почему?
Потому что строка с NULL слева была вычтена строкой с NULL справа.
Для EXCEPT они считаются одинаковыми в рамках сравнения строк.
Это отличается от обычного условия:
a.country = b.country
Такое условие не считает NULL равным NULL.
Поэтому при сравнении данных с NULL EXCEPT иногда оказывается очень удобным: он сравнивает строки как элементы набора, а не через обычное =.
EXCEPT против NOT EXISTS
Разность выборок можно выразить не только через EXCEPT, но и через NOT EXISTS.
Например, пользователи без заказов через EXCEPT:
SELECT id
FROM users
EXCEPT
SELECT user_id
FROM orders;
То же самое через NOT EXISTS:
SELECT u.id
FROM users u
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.id
);
Оба варианта могут вернуть пользователей, у которых нет заказов.
Но смысл записи немного разный.
EXCEPT работает с двумя готовыми наборами:
все id пользователей
минус
user_id из заказов
NOT EXISTS проверяет каждую строку из users:
оставь пользователя, если для него не существует заказа
Когда лучше EXCEPT, а когда NOT EXISTS
EXCEPT хорошо подходит, когда нужно сравнить два набора строк целиком.
Например:
SELECT id, user_id, amount
FROM orders_source
EXCEPT
SELECT id, user_id, amount
FROM orders_target;
Такой запрос сразу показывает строки, которые есть в источнике, но отсутствуют в целевой таблице.
NOT EXISTS часто удобнее, когда:
- нужно вывести дополнительные колонки из первой таблицы;
- сравнение идёт по ключу;
- нужно сохранить дубликаты из левой таблицы;
- важна производительность на больших таблицах;
- нужно гибко прописать условия сравнения.
Например:
SELECT u.id, u.email, u.created_at
FROM users u
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.id
);
Здесь мы выводим не просто id, а любые поля из users.
Через EXCEPT это тоже можно сделать, но если сравнение идёт только по id, NOT EXISTS часто читается естественнее.
EXCEPT против LEFT JOIN ... IS NULL
Ещё один популярный способ найти строки без совпадения — LEFT JOIN.
Например:
SELECT u.id, u.email
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.user_id IS NULL;
Смысл:
- берём всех пользователей;
- пытаемся присоединить к ним заказы;
- оставляем только тех, у кого заказ не нашёлся.
Это называется anti-join, то есть «соединение наоборот»: найти строки без пары.
Такой подход часто встречается в рабочих запросах.
Но у него есть нюансы:
- если в правой таблице дубли,
JOIN может размножать строки до фильтрации;
- нужно аккуратно выбирать колонку для проверки
IS NULL;
- если условие соединения сложное, запрос может стать длинным;
EXCEPT иногда читается проще, когда нужно сравнить одинаковые наборы колонок.
Пример через EXCEPT:
SELECT id
FROM users
EXCEPT
SELECT user_id
FROM orders;
Пример через LEFT JOIN:
SELECT u.id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.user_id IS NULL;
Оба варианта имеют право на жизнь. Выбор зависит от задачи, данных и привычек команды.
Почему NOT IN может быть опасен с NULL
Есть ещё один способ, который часто пишут новички:
SELECT id
FROM users
WHERE id NOT IN (
SELECT user_id
FROM orders
);
На первый взгляд всё логично: найти пользователей, чей id не входит в список user_id из заказов.
Но с NOT IN есть опасная ловушка.
Если подзапрос вернёт хотя бы один NULL, результат может стать неожиданным и даже пустым.
Например, если список справа такой:
user_id
-------
1
3
NULL
условие:
id NOT IN (1, 3, NULL)
становится проблемным, потому что SQL не может уверенно сказать, что id не равен неизвестному значению.
Поэтому для таких задач часто безопаснее использовать NOT EXISTS:
SELECT u.id
FROM users u
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.id
);
Или использовать EXCEPT:
SELECT id
FROM users
EXCEPT
SELECT user_id
FROM orders;
Можно запомнить так:
NOT IN и NULL — опасное сочетание.
Если в правой выборке может быть NULL, лучше использовать NOT EXISTS или хорошо понимать последствия.
Сверка данных через EXCEPT
Одна из самых полезных задач для EXCEPT — сверка данных.
Например, вы переносите заказы из старой системы в новую.
Есть таблица-источник:
orders_source
И таблица-приёмник:
orders_target
Нужно проверить, какие строки есть в источнике, но не попали в приёмник.
SELECT id, user_id, amount, status, created_at
FROM orders_source
EXCEPT
SELECT id, user_id, amount, status, created_at
FROM orders_target;
Если запрос вернул строки, значит эти строки есть в источнике, но отсутствуют в целевой таблице.
Если результат пустой, значит все такие строки из источника есть в приёмнике.
Но это только одна сторона проверки.
Двусторонняя сверка
Чтобы убедиться, что две таблицы совпадают полностью, нужно проверить разность в обе стороны.
Первая проверка:
SELECT id, user_id, amount, status, created_at
FROM orders_source
EXCEPT
SELECT id, user_id, amount, status, created_at
FROM orders_target;
Она показывает строки, которые есть в источнике, но отсутствуют в приёмнике.
Вторая проверка:
SELECT id, user_id, amount, status, created_at
FROM orders_target
EXCEPT
SELECT id, user_id, amount, status, created_at
FROM orders_source;
Она показывает строки, которые есть в приёмнике, но отсутствуют в источнике.
Если обе проверки вернули пустой результат, значит таблицы совпадают по выбранным колонкам.
Это гораздо надёжнее, чем просто сравнить количество строк.
Почему?
Потому что одинаковый COUNT(*) ещё не означает, что строки одинаковые.
В одной таблице может быть строка A, в другой — строка B, а количество всё равно совпадает.
Сверка с меткой направления через CTE
Чтобы сразу увидеть расхождения в обе стороны, можно объединить две разности и добавить колонку с направлением. Чтобы не путаться с приоритетом операций, удобнее использовать CTE.
WITH source_minus_target AS (
SELECT id, user_id, amount, status, created_at
FROM orders_source
EXCEPT
SELECT id, user_id, amount, status, created_at
FROM orders_target
),
target_minus_source AS (
SELECT id, user_id, amount, status, created_at
FROM orders_target
EXCEPT
SELECT id, user_id, amount, status, created_at
FROM orders_source
)
SELECT 'source_minus_target' AS diff_type, *
FROM source_minus_target
UNION ALL
SELECT 'target_minus_source' AS diff_type, *
FROM target_minus_source;
Так результат покажет не только отличающиеся строки, но и направление расхождения.
EXCEPT и ORDER BY
Если нужен порядок строк, ORDER BY пишется в самом конце.
Например:
SELECT email
FROM users
EXCEPT
SELECT email
FROM unsubscribed_users
ORDER BY email;
Без ORDER BY порядок результата не гарантирован.
Даже если база сейчас возвращает строки «в красивом порядке», полагаться на это нельзя. План выполнения может измениться, данных станет больше, и порядок станет другим.
Правило простое:
Нужен порядок — добавьте ORDER BY.
EXCEPT и LIMIT
LIMIT в конце применяется ко всему результату после вычитания.
SELECT email
FROM users
EXCEPT
SELECT email
FROM unsubscribed_users
ORDER BY email
LIMIT 10;
Этот запрос:
- возьмёт email из
users;
- вычтет email из
unsubscribed_users;
- отсортирует результат;
- вернёт первые 10 строк.
Если нужно ограничить только одну ветку до EXCEPT, используйте подзапрос.
Например:
SELECT email
FROM (
SELECT email
FROM users
ORDER BY created_at DESC
LIMIT 100
) AS recent_users
EXCEPT
SELECT email
FROM unsubscribed_users;
Здесь сначала берутся 100 последних пользователей, а потом из них вычитаются те, кто отписался.
EXCEPT и производительность
EXCEPT очень удобен для чтения, особенно в задачах сверки. Но нужно понимать, что база выполняет реальную работу.
Обычный EXCEPT должен:
- получить строки из первой выборки;
- получить строки из второй выборки;
- сравнить их;
- убрать строки справа из результата слева;
- удалить дубликаты.
На небольших таблицах это обычно не проблема.
Но на больших объёмах EXCEPT может быть тяжелее, чем NOT EXISTS, особенно если вы сравниваете большие широкие строки.
Например, такой запрос:
SELECT id, user_id, amount, status, created_at
FROM orders_source
EXCEPT
SELECT id, user_id, amount, status, created_at
FROM orders_target;
может потребовать много памяти, потому что база сравнивает строки целиком.
Если же вам нужно проверить отсутствие только по ключу, иногда быстрее и понятнее использовать NOT EXISTS:
SELECT s.*
FROM orders_source s
WHERE NOT EXISTS (
SELECT 1
FROM orders_target t
WHERE t.id = s.id
);
Такой запрос сравнивает строки по ключу id, а не по всей строке.
Практическое правило:
Для сверки одинаковых наборов колонок EXCEPT очень удобен.
Для поиска отсутствующих строк по ключу на больших таблицах часто стоит проверить вариант с NOT EXISTS.
Лучше не гадать, а смотреть план выполнения через EXPLAIN или EXPLAIN ANALYZE.
EXCEPT в PostgreSQL
В PostgreSQL EXCEPT поддерживается полноценно.
Можно использовать:
EXCEPT
и:
EXCEPT ALL
Обычный EXCEPT удаляет дубликаты.
EXCEPT ALL учитывает количество повторов.
PostgreSQL строго проверяет количество колонок и совместимость типов. Если структура двух SELECT не совпадает, запрос завершится ошибкой.
EXCEPT в Oracle
В Oracle аналогичная операция традиционно называется:
MINUS
Пример:
SELECT user_id
FROM users
MINUS
SELECT user_id
FROM banned_users;
По смыслу это то же самое: строки из первой выборки минус строки из второй.
Если вы переходите между PostgreSQL и Oracle, важно помнить это различие в названии:
PostgreSQL: EXCEPT
Oracle: MINUS
EXCEPT в MySQL
В старых версиях MySQL оператора EXCEPT не было, поэтому разность выборок обычно писали через NOT EXISTS, LEFT JOIN ... IS NULL или NOT IN с осторожностью.
Например:
SELECT u.id
FROM users u
WHERE NOT EXISTS (
SELECT 1
FROM banned_users b
WHERE b.user_id = u.id
);
В современных версиях MySQL поддержка операций множеств стала шире, но при работе с конкретным проектом всё равно лучше проверить версию сервера и поддерживаемый синтаксис.
Если нужно писать максимально переносимый запрос, NOT EXISTS часто оказывается более универсальным вариантом.
EXCEPT в ClickHouse
В ClickHouse операции множеств тоже зависят от версии и настроек. Для аналитических задач часто используют UNION ALL, DISTINCT, JOIN, ANTI JOIN, NOT IN или подзапросы.
Если вы хотите использовать именно EXCEPT, лучше проверить поведение на вашей версии ClickHouse: особенно то, как она работает с дубликатами и поддерживает ли нужную вам форму ALL или DISTINCT.
Для больших аналитических таблиц в ClickHouse часто важнее не только синтаксис, но и то, как данные отсортированы, партиционированы и какие условия попадают в запрос.
Практические шаблоны
Все пользователи минус заблокированные
SELECT id
FROM users
EXCEPT
SELECT user_id
FROM banned_users;
Пользователи без заказов
SELECT id
FROM users
EXCEPT
SELECT user_id
FROM orders;
Email для рассылки без отписавшихся
SELECT email
FROM users
EXCEPT
SELECT email
FROM unsubscribed_users;
Товары, которые пропали из нового каталога
SELECT sku
FROM old_catalog
EXCEPT
SELECT sku
FROM new_catalog;
Строки, которые есть в источнике, но отсутствуют в приёмнике
SELECT id, user_id, amount, status, created_at
FROM orders_source
EXCEPT
SELECT id, user_id, amount, status, created_at
FROM orders_target;
Двусторонняя сверка через CTE
WITH source_minus_target AS (
SELECT id, user_id, amount
FROM orders_source
EXCEPT
SELECT id, user_id, amount
FROM orders_target
),
target_minus_source AS (
SELECT id, user_id, amount
FROM orders_target
EXCEPT
SELECT id, user_id, amount
FROM orders_source
)
SELECT 'source_minus_target' AS diff_type, *
FROM source_minus_target
UNION ALL
SELECT 'target_minus_source' AS diff_type, *
FROM target_minus_source;
Альтернатива через NOT EXISTS
SELECT u.id, u.email
FROM users u
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.id
);
Альтернатива через LEFT JOIN
SELECT u.id, u.email
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.user_id IS NULL;
Что важно запомнить
EXCEPT возвращает строки из первой выборки, которых нет во второй.
Пример:
SELECT id FROM users
EXCEPT
SELECT user_id FROM orders;
Такой запрос найдёт пользователей, у которых нет заказов.
Главные правила:
- первый
SELECT — основной набор;
- второй
SELECT — набор, который вычитаем;
- количество колонок должно совпадать;
- типы колонок должны быть совместимы;
- порядок колонок важен;
- сравнивается вся строка целиком;
- обычный
EXCEPT убирает дубликаты;
EXCEPT ALL учитывает количество повторов;
- строки с
NULL в одинаковых позициях считаются совпадающими;
- порядок результата не гарантирован без
ORDER BY.
EXCEPT особенно полезен для сверки данных: найти строки, которые есть в одном источнике, но отсутствуют в другом.
Короткий вывод
EXCEPT — это SQL-оператор для разности выборок.
Он отвечает на вопрос:
Какие строки есть в первом запросе, но отсутствуют во втором?
Например:
SELECT email FROM users
EXCEPT
SELECT email FROM unsubscribed_users;
Так можно получить пользователей, которые не отписались от рассылки.
Можно запомнить три операции вместе:
UNION — объединить выборки
INTERSECT — найти общее
EXCEPT — вычесть одну выборку из другой
EXCEPT хорош, когда вы сравниваете два набора строк целиком: например, при миграции, сверке отчётов, проверке каталога или поиске отсутствующих записей.
Если же нужно проверить отсутствие по ключу на больших таблицах, часто стоит рассмотреть NOT EXISTS.
Главное — перед запросом проговорить смысл:
Я вычитаю строки целиком или только ключи?
Мне важны дубликаты или нужен уникальный результат?
В колонках могут быть NULL?
Ответы на эти вопросы помогают выбрать правильный инструмент и не получить красивый, но неверный отчёт.
В SQL есть операции, которые помогают работать с результатами запросов как с наборами строк.
Например:
UNIONобъединяет выборки.
INTERSECTоставляет только строки, которые есть в обеих выборках.
А:
EXCEPTделает обратную задачу: показывает строки, которые есть в первой выборке, но которых нет во второй.
То есть
EXCEPTотвечает на вопрос:Это очень полезно в реальных задачах:
Если
INTERSECT— это «общее», тоEXCEPT— это «разница».Что делает EXCEPT простыми словами
Представим два списка.
Первый список — все пользователи:
Второй список — заблокированные пользователи:
Если мы хотим получить всех пользователей, кроме заблокированных, нам нужно из первого списка вычесть второй.
Останется:
В SQL это можно записать через
EXCEPT:SELECT user_id FROM users EXCEPT SELECT user_id FROM banned_users;Смысл такой:
Базовый синтаксис EXCEPT
Общий вид запроса:
SELECT column_1, column_2 FROM first_table EXCEPT SELECT column_1, column_2 FROM second_table;Первый
SELECT— это основной набор.Второй
SELECT— это набор, который нужно вычесть.Например:
SELECT email FROM users EXCEPT SELECT email FROM unsubscribed_users;Такой запрос вернёт email пользователей, которые есть в
users, но отсутствуют вunsubscribed_users.То есть это пользователи, которые не отписались.
Пример: пользователи без заказов
Допустим, есть таблица
users:И таблица
orders:Нужно найти пользователей, у которых нет заказов.
Первый набор — все пользователи:
SELECT id FROM users;Результат:
Второй набор — пользователи, у которых есть заказы:
SELECT user_id FROM orders;Результат:
Теперь вычитаем второй набор из первого:
SELECT id FROM users EXCEPT SELECT user_id FROM orders;Результат:
Пользователи
2и4есть в таблицеusers, но их нет средиuser_idв заказах.Значит, они зарегистрировались, но ещё ничего не заказали.
EXCEPT — это не JOIN
Новички иногда пытаются решить все задачи через
JOIN, ноEXCEPTработает по другой логике.JOINсоединяет строки из разных таблиц.А
EXCEPTсравнивает два готовых результатаSELECTи вычитает один из другого.Например:
SELECT id FROM users EXCEPT SELECT user_id FROM orders;Этот запрос не соединяет таблицы. Он просто берёт один список значений и вычитает из него другой список.
Можно запомнить так:
Если вопрос звучит как «каких строк из первой выборки нет во второй», часто стоит подумать именно про
EXCEPT.EXCEPT сравнивает строки целиком
Очень важный момент:
EXCEPTсравнивает всю строку целиком, а не только первую колонку.Например:
SELECT 'anna@mail.com' AS email, 'Vietnam' AS country EXCEPT SELECT 'anna@mail.com' AS email, 'Germany' AS country;Результат будет:
Почему строка осталась?
Потому что полностью она не совпала со строкой справа.
Слева:
Справа:
Email одинаковый, но страна разная. Для
EXCEPTэто разные строки.А вот такой запрос вернёт пустой результат:
SELECT 'anna@mail.com' AS email, 'Vietnam' AS country EXCEPT SELECT 'anna@mail.com' AS email, 'Vietnam' AS country;Потому что строка справа полностью совпала со строкой слева и вычла её.
Главное правило:
Если вы хотите вычитать только по email, выбирайте только email:
SELECT email FROM users EXCEPT SELECT email FROM unsubscribed_users;Если добавите
country,created_at,sourceили другие колонки, они тоже будут участвовать в сравнении.Требования к SELECT внутри EXCEPT
У двух запросов должно совпадать количество колонок.
Так можно:
SELECT id, email FROM users EXCEPT SELECT id, email FROM archived_users;В обоих запросах по две колонки.
А так нельзя:
SELECT id, email FROM users EXCEPT SELECT id, email, country FROM archived_users;Первый запрос возвращает две колонки, а второй — три. База не сможет сравнить такие строки.
Также важен порядок колонок.
Например:
SELECT id, email FROM users EXCEPT SELECT email, id FROM archived_users;Даже если запрос технически выполнится, смысл будет неправильным: база будет сравнивать
idсemail, аemailсid.Поэтому при
EXCEPTважно следить за тремя вещами:Имена колонок берутся из первого SELECT
Названия колонок в итоговом результате берутся из первого
SELECT.Например:
SELECT id, email FROM users EXCEPT SELECT user_id, contact_email FROM old_users;Итоговые колонки будут называться:
Даже если во втором запросе они называются
user_idиcontact_email.Поэтому первый
SELECTлучше оформлять аккуратно:SELECT id AS user_id, email AS user_email FROM users EXCEPT SELECT user_id, contact_email FROM old_users;Так результат будет понятнее.
EXCEPT убирает дубликаты
Обычный
EXCEPTработает как операция над множествами. А в множестве одинаковые строки хранятся один раз.Например:
SELECT 'SQL' AS course EXCEPT SELECT 'Python' AS course;Результат:
А если слева строка повторяется несколько раз, обычный
EXCEPTвсё равно вернёт её максимум один раз.Представим левую выборку:
Правая выборка:
Обычный
EXCEPTвернёт:Пользователь
1был слева три раза, но в результате появился один раз.Потому что
EXCEPTпо умолчанию убирает дубликаты.EXCEPT ALL: разность с учётом повторов
У
EXCEPTесть вариант:EXCEPT ALLОн учитывает количество повторов.
Логика такая:
Например, слева:
Справа:
Для
user_id = 1:Для
user_id = 2:Для
user_id = 3:Результат
EXCEPT ALL:Пример запроса:
SELECT user_id FROM orders EXCEPT ALL SELECT user_id FROM refunded_orders;Обычный
EXCEPTвернул бы уникальные значения.EXCEPT ALLсохраняет кратность, то есть количество повторов.На практике обычный
EXCEPTиспользуется чаще, потому что во многих задачах важен сам факт отсутствия строки во второй выборке. Но если вы сравниваете фактическое количество строк или ищете расхождения в дублях,EXCEPT ALLможет быть полезен.Пример: товары, которые пропали из нового каталога
Допустим, есть старый каталог товаров:
И новый каталог:
Нужно найти товары, которые были в старом каталоге, но отсутствуют в новом.
SELECT sku FROM old_catalog EXCEPT SELECT sku FROM new_catalog;Результат:
Это классический сценарий для
EXCEPT.Мы как будто спрашиваем:
Пример: клиенты, которые не отписались
Допустим, есть таблица
users:И таблица
unsubscribed_users:Нужно получить email пользователей, которым можно отправить рассылку.
SELECT email FROM users EXCEPT SELECT email FROM unsubscribed_users;Результат:
Такой запрос коротко выражает бизнес-логику:
EXCEPT и NULL
В обычных сравнениях SQL
NULLведёт себя необычно.Например:
SELECT NULL = NULL;не возвращает
TRUE.Но в операциях над множествами, таких как
EXCEPT, строки сNULLв одинаковых позициях считаются совпадающими.Пример:
SELECT NULL AS country EXCEPT SELECT NULL AS country;Результат будет пустым.
Почему?
Потому что строка с
NULLслева была вычтена строкой сNULLсправа.Для
EXCEPTони считаются одинаковыми в рамках сравнения строк.Это отличается от обычного условия:
a.country = b.countryТакое условие не считает
NULLравнымNULL.Поэтому при сравнении данных с
NULLEXCEPTиногда оказывается очень удобным: он сравнивает строки как элементы набора, а не через обычное=.EXCEPT против NOT EXISTS
Разность выборок можно выразить не только через
EXCEPT, но и черезNOT EXISTS.Например, пользователи без заказов через
EXCEPT:SELECT id FROM users EXCEPT SELECT user_id FROM orders;То же самое через
NOT EXISTS:SELECT u.id FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id );Оба варианта могут вернуть пользователей, у которых нет заказов.
Но смысл записи немного разный.
EXCEPTработает с двумя готовыми наборами:NOT EXISTSпроверяет каждую строку изusers:Когда лучше EXCEPT, а когда NOT EXISTS
EXCEPTхорошо подходит, когда нужно сравнить два набора строк целиком.Например:
SELECT id, user_id, amount FROM orders_source EXCEPT SELECT id, user_id, amount FROM orders_target;Такой запрос сразу показывает строки, которые есть в источнике, но отсутствуют в целевой таблице.
NOT EXISTSчасто удобнее, когда:Например:
SELECT u.id, u.email, u.created_at FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id );Здесь мы выводим не просто
id, а любые поля изusers.Через
EXCEPTэто тоже можно сделать, но если сравнение идёт только поid,NOT EXISTSчасто читается естественнее.EXCEPT против LEFT JOIN ... IS NULL
Ещё один популярный способ найти строки без совпадения —
LEFT JOIN.Например:
SELECT u.id, u.email FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.user_id IS NULL;Смысл:
Это называется anti-join, то есть «соединение наоборот»: найти строки без пары.
Такой подход часто встречается в рабочих запросах.
Но у него есть нюансы:
JOINможет размножать строки до фильтрации;IS NULL;EXCEPTиногда читается проще, когда нужно сравнить одинаковые наборы колонок.Пример через
EXCEPT:SELECT id FROM users EXCEPT SELECT user_id FROM orders;Пример через
LEFT JOIN:SELECT u.id FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.user_id IS NULL;Оба варианта имеют право на жизнь. Выбор зависит от задачи, данных и привычек команды.
Почему NOT IN может быть опасен с NULL
Есть ещё один способ, который часто пишут новички:
SELECT id FROM users WHERE id NOT IN ( SELECT user_id FROM orders );На первый взгляд всё логично: найти пользователей, чей
idне входит в списокuser_idиз заказов.Но с
NOT INесть опасная ловушка.Если подзапрос вернёт хотя бы один
NULL, результат может стать неожиданным и даже пустым.Например, если список справа такой:
условие:
id NOT IN (1, 3, NULL)становится проблемным, потому что SQL не может уверенно сказать, что
idне равен неизвестному значению.Поэтому для таких задач часто безопаснее использовать
NOT EXISTS:SELECT u.id FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id );Или использовать
EXCEPT:SELECT id FROM users EXCEPT SELECT user_id FROM orders;Можно запомнить так:
Сверка данных через EXCEPT
Одна из самых полезных задач для
EXCEPT— сверка данных.Например, вы переносите заказы из старой системы в новую.
Есть таблица-источник:
И таблица-приёмник:
Нужно проверить, какие строки есть в источнике, но не попали в приёмник.
SELECT id, user_id, amount, status, created_at FROM orders_source EXCEPT SELECT id, user_id, amount, status, created_at FROM orders_target;Если запрос вернул строки, значит эти строки есть в источнике, но отсутствуют в целевой таблице.
Если результат пустой, значит все такие строки из источника есть в приёмнике.
Но это только одна сторона проверки.
Двусторонняя сверка
Чтобы убедиться, что две таблицы совпадают полностью, нужно проверить разность в обе стороны.
Первая проверка:
SELECT id, user_id, amount, status, created_at FROM orders_source EXCEPT SELECT id, user_id, amount, status, created_at FROM orders_target;Она показывает строки, которые есть в источнике, но отсутствуют в приёмнике.
Вторая проверка:
SELECT id, user_id, amount, status, created_at FROM orders_target EXCEPT SELECT id, user_id, amount, status, created_at FROM orders_source;Она показывает строки, которые есть в приёмнике, но отсутствуют в источнике.
Если обе проверки вернули пустой результат, значит таблицы совпадают по выбранным колонкам.
Это гораздо надёжнее, чем просто сравнить количество строк.
Почему?
Потому что одинаковый
COUNT(*)ещё не означает, что строки одинаковые.В одной таблице может быть строка
A, в другой — строкаB, а количество всё равно совпадает.Сверка с меткой направления через CTE
Чтобы сразу увидеть расхождения в обе стороны, можно объединить две разности и добавить колонку с направлением. Чтобы не путаться с приоритетом операций, удобнее использовать CTE.
WITH source_minus_target AS ( SELECT id, user_id, amount, status, created_at FROM orders_source EXCEPT SELECT id, user_id, amount, status, created_at FROM orders_target ), target_minus_source AS ( SELECT id, user_id, amount, status, created_at FROM orders_target EXCEPT SELECT id, user_id, amount, status, created_at FROM orders_source ) SELECT 'source_minus_target' AS diff_type, * FROM source_minus_target UNION ALL SELECT 'target_minus_source' AS diff_type, * FROM target_minus_source;Так результат покажет не только отличающиеся строки, но и направление расхождения.
EXCEPT и ORDER BY
Если нужен порядок строк,
ORDER BYпишется в самом конце.Например:
SELECT email FROM users EXCEPT SELECT email FROM unsubscribed_users ORDER BY email;Без
ORDER BYпорядок результата не гарантирован.Даже если база сейчас возвращает строки «в красивом порядке», полагаться на это нельзя. План выполнения может измениться, данных станет больше, и порядок станет другим.
Правило простое:
EXCEPT и LIMIT
LIMITв конце применяется ко всему результату после вычитания.SELECT email FROM users EXCEPT SELECT email FROM unsubscribed_users ORDER BY email LIMIT 10;Этот запрос:
users;unsubscribed_users;Если нужно ограничить только одну ветку до
EXCEPT, используйте подзапрос.Например:
SELECT email FROM ( SELECT email FROM users ORDER BY created_at DESC LIMIT 100 ) AS recent_users EXCEPT SELECT email FROM unsubscribed_users;Здесь сначала берутся 100 последних пользователей, а потом из них вычитаются те, кто отписался.
EXCEPT и производительность
EXCEPTочень удобен для чтения, особенно в задачах сверки. Но нужно понимать, что база выполняет реальную работу.Обычный
EXCEPTдолжен:На небольших таблицах это обычно не проблема.
Но на больших объёмах
EXCEPTможет быть тяжелее, чемNOT EXISTS, особенно если вы сравниваете большие широкие строки.Например, такой запрос:
SELECT id, user_id, amount, status, created_at FROM orders_source EXCEPT SELECT id, user_id, amount, status, created_at FROM orders_target;может потребовать много памяти, потому что база сравнивает строки целиком.
Если же вам нужно проверить отсутствие только по ключу, иногда быстрее и понятнее использовать
NOT EXISTS:SELECT s.* FROM orders_source s WHERE NOT EXISTS ( SELECT 1 FROM orders_target t WHERE t.id = s.id );Такой запрос сравнивает строки по ключу
id, а не по всей строке.Практическое правило:
Лучше не гадать, а смотреть план выполнения через
EXPLAINилиEXPLAIN ANALYZE.EXCEPT в PostgreSQL
В PostgreSQL
EXCEPTподдерживается полноценно.Можно использовать:
EXCEPTи:
EXCEPT ALLОбычный
EXCEPTудаляет дубликаты.EXCEPT ALLучитывает количество повторов.PostgreSQL строго проверяет количество колонок и совместимость типов. Если структура двух
SELECTне совпадает, запрос завершится ошибкой.EXCEPT в Oracle
В Oracle аналогичная операция традиционно называется:
Пример:
SELECT user_id FROM users MINUS SELECT user_id FROM banned_users;По смыслу это то же самое: строки из первой выборки минус строки из второй.
Если вы переходите между PostgreSQL и Oracle, важно помнить это различие в названии:
EXCEPT в MySQL
В старых версиях MySQL оператора
EXCEPTне было, поэтому разность выборок обычно писали черезNOT EXISTS,LEFT JOIN ... IS NULLилиNOT INс осторожностью.Например:
SELECT u.id FROM users u WHERE NOT EXISTS ( SELECT 1 FROM banned_users b WHERE b.user_id = u.id );В современных версиях MySQL поддержка операций множеств стала шире, но при работе с конкретным проектом всё равно лучше проверить версию сервера и поддерживаемый синтаксис.
Если нужно писать максимально переносимый запрос,
NOT EXISTSчасто оказывается более универсальным вариантом.EXCEPT в ClickHouse
В ClickHouse операции множеств тоже зависят от версии и настроек. Для аналитических задач часто используют
UNION ALL,DISTINCT,JOIN,ANTI JOIN,NOT INили подзапросы.Если вы хотите использовать именно
EXCEPT, лучше проверить поведение на вашей версии ClickHouse: особенно то, как она работает с дубликатами и поддерживает ли нужную вам формуALLилиDISTINCT.Для больших аналитических таблиц в ClickHouse часто важнее не только синтаксис, но и то, как данные отсортированы, партиционированы и какие условия попадают в запрос.
Практические шаблоны
Все пользователи минус заблокированные
SELECT id FROM users EXCEPT SELECT user_id FROM banned_users;Пользователи без заказов
SELECT id FROM users EXCEPT SELECT user_id FROM orders;Email для рассылки без отписавшихся
SELECT email FROM users EXCEPT SELECT email FROM unsubscribed_users;Товары, которые пропали из нового каталога
SELECT sku FROM old_catalog EXCEPT SELECT sku FROM new_catalog;Строки, которые есть в источнике, но отсутствуют в приёмнике
SELECT id, user_id, amount, status, created_at FROM orders_source EXCEPT SELECT id, user_id, amount, status, created_at FROM orders_target;Двусторонняя сверка через CTE
WITH source_minus_target AS ( SELECT id, user_id, amount FROM orders_source EXCEPT SELECT id, user_id, amount FROM orders_target ), target_minus_source AS ( SELECT id, user_id, amount FROM orders_target EXCEPT SELECT id, user_id, amount FROM orders_source ) SELECT 'source_minus_target' AS diff_type, * FROM source_minus_target UNION ALL SELECT 'target_minus_source' AS diff_type, * FROM target_minus_source;Альтернатива через NOT EXISTS
SELECT u.id, u.email FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id );Альтернатива через LEFT JOIN
SELECT u.id, u.email FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.user_id IS NULL;Что важно запомнить
EXCEPTвозвращает строки из первой выборки, которых нет во второй.Пример:
SELECT id FROM users EXCEPT SELECT user_id FROM orders;Такой запрос найдёт пользователей, у которых нет заказов.
Главные правила:
SELECT— основной набор;SELECT— набор, который вычитаем;EXCEPTубирает дубликаты;EXCEPT ALLучитывает количество повторов;NULLв одинаковых позициях считаются совпадающими;ORDER BY.EXCEPTособенно полезен для сверки данных: найти строки, которые есть в одном источнике, но отсутствуют в другом.Короткий вывод
EXCEPT— это SQL-оператор для разности выборок.Он отвечает на вопрос:
Например:
SELECT email FROM users EXCEPT SELECT email FROM unsubscribed_users;Так можно получить пользователей, которые не отписались от рассылки.
Можно запомнить три операции вместе:
EXCEPTхорош, когда вы сравниваете два набора строк целиком: например, при миграции, сверке отчётов, проверке каталога или поиске отсутствующих записей.Если же нужно проверить отсутствие по ключу на больших таблицах, часто стоит рассмотреть
NOT EXISTS.Главное — перед запросом проговорить смысл:
Ответы на эти вопросы помогают выбрать правильный инструмент и не получить красивый, но неверный отчёт.