Операторы сравнения и логика в SQL: AND, OR, NOT и NULL
Трёхзначная логика SQL на практике: почему NOT IN возвращает пустоту, где AND незаметно съедает OR и как собрать длинный фильтр так, чтобы его можно было проверить.
Содержание статьи
Маркетинг просит выгрузку: пользователи из России, на мобильных или планшетах, без тестовых аккаунтов. Запрос написан за минуту, число получено, рассылка ушла. Через день выясняется, что в выборку попали пользователи из других стран, а половина планшетов, наоборот, потерялась. Ошибки в фильтрах почти никогда не выглядят как ошибки: запрос выполняется, число возвращается, и выглядит оно правдоподобно. Причина обычно в двух вещах — приоритете операторов и в том, что в SQL логика не двузначная.
В SQL три исхода сравнения, а не два
Привычная логика знает TRUE и FALSE. SQL добавляет третье состояние — UNKNOWN. Оно появляется, когда в сравнении участвует NULL, то есть «значение неизвестно». Неизвестное нельзя сравнить ни с чем, даже с другим неизвестным: NULL = NULL даёт не TRUE, а снова UNKNOWN.
Это меняет поведение всех операторов. AND возвращает TRUE только если обе части истинны, но при UNKNOWN результат зависит от второй части: FALSE AND UNKNOWN — это FALSE, а TRUE AND UNKNOWN — уже UNKNOWN. OR работает зеркально. А NOT не превращает UNKNOWN в TRUE: неизвестное остаётся неизвестным, сколько его ни отрицай.
Ключевое правило, из которого следует всё остальное: WHERE оставляет строку только при TRUE. И FALSE, и UNKNOWN одинаково выбрасывают строку из результата. Поэтому пара условий, которые в обычной логике покрывают всю таблицу, в SQL может не покрыть ничего.
| Выражение | Результат | Строка попадёт в WHERE? |
|---|---|---|
| country = 'RU' при country = NULL | UNKNOWN | нет |
| country <> 'RU' при country = NULL | UNKNOWN | нет |
| NOT (country = 'RU') при country = NULL | UNKNOWN | нет |
| country IS NULL | TRUE | да |
| country IS DISTINCT FROM 'RU' | TRUE | да |
Первая ловушка: «не равно» теряет строки
Аналитик считает пользователей не из России: пишет country <> 'RU' и получает 18 400. Затем считает пользователей из России: 6 600. Сумма — 25 000, а всего в таблице 27 300. Куда делись 2 300 человек?
Они лежат в строках, где country не заполнено. Такие строки не проходят ни первое условие, ни второе, потому что оба дают UNKNOWN. Пропущенные значения не исчезают из таблицы — они исчезают из обеих сторон вашего анализа, и это тихо ломает знаменатель.
Лечится либо явным добавлением OR country IS NULL, либо оператором IS DISTINCT FROM, который сравнивает с учётом NULL. Второй вариант короче и честнее показывает намерение: «всё, что отличается от RU, включая неизвестное».
SELECT
count(*) FILTER (WHERE country <> 'RU') AS naive,
count(*) FILTER (WHERE country <> 'RU' OR country IS NULL) AS with_nulls,
count(*) FILTER (WHERE country IS DISTINCT FROM 'RU') AS null_safe,
count(*) AS total,
count(country) AS country_filled
FROM users;Вторая ловушка: NOT IN со списком, где есть NULL
Эта ошибка коварнее, потому что результат не «немного другой», а пустой. Запрос вида user_id NOT IN (SELECT user_id FROM blocked_users) вернёт ноль строк, если в подзапросе окажется хотя бы один NULL.
Механика простая, если развернуть выражение. x NOT IN (1, NULL) — это NOT (x = 1 OR x = NULL). При x = 5 получаем NOT (FALSE OR UNKNOWN), то есть NOT UNKNOWN, то есть UNKNOWN. Условие никогда не станет TRUE, и в результат не попадёт ни одна строка. Обратите внимание: обычный IN при этом работает нормально, ошибка живёт именно в отрицании.
Поэтому для исключения по списку из подзапроса надёжнее NOT EXISTS: он проверяет наличие строки, а не сравнивает значения, и NULL его не ломает. Если всё же нужен NOT IN, отфильтруйте NULL в подзапросе явно.
-- ломается, если blocked_users.user_id содержит NULL
SELECT * FROM users
WHERE user_id NOT IN (SELECT user_id FROM blocked_users);
-- вариант 1: убрать NULL из списка
SELECT * FROM users
WHERE user_id NOT IN (
SELECT user_id FROM blocked_users WHERE user_id IS NOT NULL
);
-- вариант 2: анти-джойн, устойчивый к NULL
SELECT u.*
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM blocked_users b WHERE b.user_id = u.user_id
);Третья ловушка: AND связывает крепче, чем OR
В SQL AND имеет более высокий приоритет, чем OR, — как умножение по отношению к сложению. Условие channel = 'organic' OR channel = 'paid' AND country = 'RU' база читает как «organic из любой страны ИЛИ paid из России». Человек, который писал запрос, почти наверняка имел в виду другое.
Именно так и появляются лишние пользователи в выгрузке: одна ветка OR оказывается без ограничения по стране и тянет за собой всю базу. Число при этом выглядит нормально — оно просто больше, чем должно быть, и без сверки этого не видно.
Правило простое: если в условии встретился OR, ставьте скобки вокруг всей группы. Не потому, что база иначе не разберётся, а потому, что запрос будут читать люди — включая вас через месяц.
| Запись | Как понимает SQL | Кто попадёт в выборку |
|---|---|---|
| channel = 'organic' OR channel = 'paid' AND country = 'RU' | organic OR (paid AND RU) | весь organic мира плюс paid из России |
| (channel = 'organic' OR channel = 'paid') AND country = 'RU' | (organic OR paid) AND RU | только пользователи из России |
| channel IN ('organic', 'paid') AND country = 'RU' | то же самое, но короче | только пользователи из России |
Отрицание группы условий
Отдельный источник ошибок — попытка «взять всех остальных». Аналитик описал целевой сегмент, потом захотел его дополнение и написал NOT (is_ru AND is_mobile). Это не «все, кто не из России и не с мобильных», а «все, у кого хотя бы одно из условий не выполнено» — включая пользователей из России с десктопа.
Работает обычное правило: отрицание конъюнкции превращается в дизъюнкцию отрицаний. NOT (a AND b) эквивалентно NOT a OR NOT b, а NOT (a OR b) — это NOT a AND NOT b. В голове это преобразование делается легко, в шестистрочном фильтре — уже нет, поэтому дополнение сегмента лучше считать не отрицанием, а вычитанием: полная база минус целевой сегмент.
И снова про пропуски: если внутри отрицаемой группы есть условие, дающее UNKNOWN, дополнение не будет полным. Сумма сегмента и его «отрицания» окажется меньше общего числа строк ровно на количество строк с NULL. Это удобная проверка — если суммы не сходятся, ищите незакрытый пропуск.
Пустая строка, ноль и NULL — три разные вещи
В аналитических таблицах пропуск приходит в трёх видах: настоящий NULL, пустая строка после выгрузки из CSV и строка 'null' или '-' от какого-нибудь интеграционного слоя. Фильтр country IS NULL найдёт только первый вид, а два других тихо попадут в «заполненные».
Перед тем как писать логику, полезно один раз посмотреть на состав значений в колонке. Если пропуски разъехались по видам, нормализуйте их на входе — через NULLIF или явное правило в трансформации, а не в каждом отчёте отдельно.
Заодно стоит помнить, как NULL ведёт себя за пределами WHERE. Агрегаты его игнорируют: count(country) считает только заполненные значения, а avg считает среднее по непустым. GROUP BY, наоборот, собирает все NULL в одну группу. А в ORDER BY в PostgreSQL NULL по умолчанию оказываются в конце при сортировке по возрастанию.
SELECT
count(*) AS rows_total,
count(*) FILTER (WHERE country IS NULL) AS real_null,
count(*) FILTER (WHERE country = '') AS empty_string,
count(*) FILTER (WHERE lower(country) IN ('null', 'na', '-')) AS fake_null,
count(DISTINCT country) AS distinct_values
FROM users;Длинный фильтр разбирается на именованные флаги
Когда в WHERE набирается пять-шесть условий со скобками, проверить его глазами уже невозможно. Помогает перенос логики в CTE: каждое бизнес-правило вычисляется как отдельный булев флаг с понятным именем, а финальный фильтр собирается из этих флагов.
У такого запроса есть практическое преимущество. Флаги можно вывести в SELECT и посчитать, сколько строк проходит каждое правило по отдельности. Это превращает отладку фильтра из угадывания в обычную арифметику: видно, какое именно условие срезало аудиторию сильнее, чем ожидалось.
WITH flags AS (
SELECT
user_id,
country = 'RU' AS is_ru,
device IN ('mobile', 'tablet') AS is_mobile_device,
is_test IS NOT TRUE AS is_real_account,
last_seen_at >= current_date - 30 AS is_active_30d
FROM users
)
SELECT
count(*) AS base,
count(*) FILTER (WHERE is_ru) AS ru,
count(*) FILTER (WHERE is_ru AND is_mobile_device) AS ru_mobile,
count(*) FILTER (WHERE is_ru AND is_mobile_device AND is_real_account) AS ru_mobile_real,
count(*) FILTER (WHERE is_ru AND is_mobile_device AND is_real_account AND is_active_30d) AS final
FROM flags;Как проверить фильтр за две минуты
Самый быстрый способ поймать ошибку логики — не перечитывать запрос, а посмотреть, как аудитория сужается по шагам. Резкий провал на одном условии почти всегда означает не строгий бизнес-фильтр, а потерянные NULL.
Второй приём — четыре контрольные строки. Возьмите пользователя, который точно должен попасть, пользователя, который точно не должен, строку с NULL в проверяемом поле и граничный случай вроде последнего дня периода. Предскажите результат словами до запуска и сравните.
Данные иллюстративные. Смотреть нужно не на итог, а на шаг с самым резким падением: обычно там и прячется NULL или лишний AND.
Чеклист перед выгрузкой
Логика фильтра — это тоже расчёт, и его стоит проверять так же, как проверяют метрику.
- В каждом условии с OR стоят скобки вокруг группы?
- Для полей с пропусками решено явно, куда попадают NULL: в выборку, в исключение или в отдельную строку отчёта?
- Вместо
NOT INс подзапросом используетсяNOT EXISTS? - Проверено, что сумма взаимодополняющих условий даёт общее число строк?
- Пустая строка и текстовые заглушки нормализованы до NULL, а не считаются заполненными значениями?
- Длинный фильтр разложен на именованные флаги, а падение аудитории по шагам объяснимо?
Материалы по теме

SQL-проверки качества данных: дубли, пропуски и скачки метрик
Практический чеклист SQL-проверок перед дашбордом: найти дубли, пропущенные ключи, события без пользователей и неожиданные скачки дневного объёма.

SQL CASE, COALESCE и NULL: как не сломать сегменты и метрики
Разбираем NULL, CASE и COALESCE на задачах аналитика: как группировать пустые значения, создавать сегменты и показывать ноль вместо пропущенных данных.
Собеседование аналитика данных: 30 задач и как решать их вслух
Большой практический гайд по собеседованию аналитика данных: SQL, Python, метрики, статистика, кейсы, дашборды и ответы, которые показывают ход мышления.