Операторы сравнения и логика в 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' и получает 1 843. Затем считает пользователей из России: 2 715. Сумма — 4 558, а всего в таблице 4 613. Куда делись 55 человек?
Они лежат в строках, где 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 в подзапросе явно.
Список блокировок запрос ниже задаёт сам — два пользователя и пропуск: NOT IN возвращает 0 строк из 4 613, оба исправленных варианта — 4 611.
-- учебная вставка: список блокировок с пропуском
WITH blocked_users (user_id) AS (
VALUES (17), (42), (CAST(NULL AS integer))
)
SELECT
-- ломается: в списке есть NULL
(SELECT count(*) FROM users
WHERE user_id NOT IN (SELECT user_id FROM blocked_users)) AS not_in_with_null,
-- вариант 1: убрать NULL из списка
(SELECT count(*) FROM users
WHERE user_id NOT IN (
SELECT user_id FROM blocked_users WHERE user_id IS NOT NULL
)) AS not_in_without_null,
-- вариант 2: анти-джойн, устойчивый к NULL
(SELECT count(*) FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM blocked_users b WHERE b.user_id = u.user_id
)) AS not_exists;Третья ловушка: AND связывает крепче, чем OR
В SQL AND имеет более высокий приоритет, чем OR, — как умножение по отношению к сложению. Условие channel = 'organic' OR channel = 'paid_search' AND country = 'RU' база читает как «organic из любой страны ИЛИ paid_search из России». Человек, который писал запрос, почти наверняка имел в виду другое.
Именно так и появляются лишние пользователи в выгрузке: одна ветка OR оказывается без ограничения по стране и тянет за собой всю базу. Число при этом выглядит нормально — оно просто больше, чем должно быть, и без сверки этого не видно.
Правило простое: если в условии встретился OR, ставьте скобки вокруг всей группы. Не потому, что база иначе не разберётся, а потому, что запрос будут читать люди — включая вас через месяц.
| Запись | Как понимает SQL | Кто попадёт в выборку |
|---|---|---|
| channel = 'organic' OR channel = 'paid_search' AND country = 'RU' | organic OR (paid_search AND RU) | весь organic мира плюс paid_search из России: 2 348 |
| (channel = 'organic' OR channel = 'paid_search') AND country = 'RU' | (organic OR paid_search) AND RU | только пользователи из России: 1 616 |
| channel IN ('organic', 'paid_search') AND country = 'RU' | то же самое, но короче | только пользователи из России: 1 616 |
Отрицание группы условий
Отдельный источник ошибок — попытка «взять всех остальных». Аналитик описал целевой сегмент, потом захотел его дополнение и написал 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, дополнение не будет полным. Сумма сегмента и его «отрицания» окажется меньше общего числа строк ровно на количество строк, где группа дала UNKNOWN. На учебной базе сегмент country = 'RU' AND device = 'mobile' и его отрицание дают 1 305 и 3 281 — вместе 4 586 из 4 613: не хватает 27 пользователей без страны на мобильных, а не всех 55. Это удобная проверка — если суммы не сходятся, ищите незакрытый пропуск.
Пустая строка, ноль и NULL — три разные вещи
В аналитических таблицах пропуск приходит в трёх видах: настоящий NULL, пустая строка после выгрузки из CSV и строка 'null' или '-' от какого-нибудь интеграционного слоя. Фильтр country IS NULL найдёт только первый вид, а два других тихо попадут в «заполненные».
Перед тем как писать логику, полезно один раз посмотреть на состав значений в колонке. Если пропуски разъехались по видам, нормализуйте их на входе — через NULLIF или явное правило в трансформации, а не в каждом отчёте отдельно. В учебной базе вид один: 55 настоящих NULL.
Заодно стоит помнить, как 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 и посчитать, сколько строк проходит каждое правило по отдельности. Это превращает отладку фильтра из угадывания в обычную арифметику: видно, какое именно условие срезало аудиторию сильнее, чем ожидалось.
Запрос ниже — выгрузка из вступления, перенесённая на учебную базу. Планшетов в ней нет, и tablet в списке ничего не добавляет. Признака тестового аккаунта тоже нет, поэтому третий флаг убирает партнёрский канал — через IS DISTINCT FROM, чтобы строка без канала не выпала вместе с партнёрами. Четвёртый флаг добавлен как условие на дату: регистрации с 1 августа.
WITH flags AS (
SELECT
user_id,
country = 'RU' AS is_ru,
device IN ('mobile', 'tablet') AS is_mobile_device,
channel IS DISTINCT FROM 'partner' AS is_not_partner,
signup_date >= DATE '2026-08-01' AS is_recent
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_not_partner) AS ru_mobile_direct,
count(*) FILTER (WHERE is_ru AND is_mobile_device AND is_not_partner AND is_recent) AS final
FROM flags;Шпаргалка по вопросам: список, диапазон, наличие и дополнение
Четыре флага из запроса выше — это четыре разных семейства операторов: равенство, вхождение в список, сравнение с учётом пропусков и сравнение с датой. Когда условий становится больше, выбор оператора удобнее делать не по форме условия, а по вопросу, на который он отвечает. Вопросов в аналитике немного, и у каждого есть вариант по умолчанию и место, где он подводит.
Таблица ниже собрана именно так: слева вопрос, в середине то, с чего стоит начинать, справа — что проверить перед публикацией. Два пункта — NOT IN с подзапросом и сравнение, устойчивое к пропускам, — разобраны выше, поэтому здесь они оставлены одной строкой для полноты карты.
| Вопрос | С чего начинать | Что проверить |
|---|---|---|
| значение входит в короткий список | channel IN ('organic', 'paid_search') | список виден в коде, а не собран строкой в приложении |
| значение входит в результат подзапроса | IN (SELECT ...) или = ANY (...) | дубли в подзапросе не размножают строки, в отличие от JOIN |
| значения нет в списке | NOT EXISTS | разобрано выше: NOT IN ломается на NULL |
| число или дата попадают в период | >= start AND < end | BETWEEN включает правую границу и срезает последний день |
| есть ли связанная строка | EXISTS | JOIN вместо EXISTS размножит левую таблицу |
| сравнить с учётом пропусков | IS DISTINCT FROM | разобрано выше: обычное <> теряет NULL |
| подставить значение вместо пропуска | COALESCE(country, 'unknown') | в отчёте видно, что это подстановка, а не реальное значение |
| превратить заглушку в пропуск | NULLIF(country, '') | нормализация стоит в одном месте, а не в каждом запросе |
| выбрать первое подходящее правило | CASE WHEN ... THEN ... ELSE ... END | порядок ветвей и явный ELSE вместо молчаливого NULL |
| значение лежит в массиве | device = ANY (ARRAY['mobile', 'tablet']) | пустой массив даёт false, а значение, которого нет в массиве с NULL, — UNKNOWN |
Сначала сформулируйте вопрос словами — «кто из пользователей есть в списке заблокированных», а не «нужен ли здесь NOT IN». Оператор после этого выбирается почти сам, а третья колонка подсказывает, какую строку данных стоит проверить руками.
INс подзапросом проверяет факт вхождения: если пользователь встречается в списке трижды, строка останется одна.JOINпо тому же списку даст три строки, и сумма вырастет втрое — поэтому для отбора аудитории берутINилиEXISTS, аJOINкогда из второй таблицы нужны колонки.BETWEENвключает обе границы. Для целых чисел это обычно то, что нужно, для времени почти никогда:event_time BETWEEN '2026-07-01' AND '2026-07-31'отрежет весь последний день, кроме ровной полуночи, — 12 174 события июля вместо 12 752.CASEвозвращает значение первой сработавшей ветви, а не самой точной:WHEN amount > 1000передWHEN amount > 5000означает, что вторая не сработает никогда. БезELSEвернётся NULL — невидимая группа в сегментации и пропущенные строки вsum.= ANY (ARRAY[...])удобен, когда список приходит параметром: один placeholder вместо генерацииINпо числу элементов. Цена та же трёхзначная логика — если в массиве есть NULL, значение, которого в нём нет, даёт UNKNOWN, а не false.
Как проверить фильтр за две минуты
Самый быстрый способ поймать ошибку логики — не перечитывать запрос, а посмотреть, как аудитория сужается по шагам. Резкий провал на одном условии почти всегда означает не строгий бизнес-фильтр, а потерянные NULL.
Второй приём — четыре контрольные строки. Возьмите пользователя, который точно должен попасть, пользователя, который точно не должен, строку с NULL в проверяемом поле и граничный случай вроде последнего дня периода. Предскажите результат словами до запуска и сравните.
Числа — результат запроса с флагами из раздела про именованные флаги. Смотреть нужно не на итог, а на шаг с самым резким падением: обычно там и прячется NULL или лишний AND. На учебной базе каждый шаг — обычный бизнес-фильтр, а пропуски видны только на шаге RU: вместе с другими странами уходят 55 пользователей без страны — флаг is_ru для них не FALSE, а NULL.
Чеклист перед выгрузкой
Логика фильтра — это тоже расчёт, и его стоит проверять так же, как проверяют метрику.
- В каждом условии с OR стоят скобки вокруг группы?
- Для полей с пропусками решено явно, куда попадают NULL: в выборку, в исключение или в отдельную строку отчёта?
- Вместо
NOT INс подзапросом используетсяNOT EXISTS? - Проверено, что сумма взаимодополняющих условий даёт общее число строк?
- Пустая строка и текстовые заглушки нормализованы до NULL, а не считаются заполненными значениями?
- Длинный фильтр разложен на именованные флаги, а падение аудитории по шагам объяснимо?
Материалы по теме

WHERE в SQL: условия, AND и OR, NULL и фильтр по дате
WHERE в SQL с примерами: несколько условий, приоритет AND и OR, IN, BETWEEN, LIKE, NULL, фильтр по дате, отличие от HAVING и способ проверить результат.

Подзапросы в SQL: IN, EXISTS, подзапрос в FROM и когда нужен JOIN или CTE
Подзапросы в SQL на реальной учебной базе: IN и EXISTS вместо JOIN, почему NOT IN возвращает ноль строк из-за NULL, подзапрос в FROM для смены зерна, скалярный и коррелированный подзапрос, подзапрос в HAVING и правило выбора между подзапросом, JOIN и CTE.
UNION и UNION ALL в SQL: где объединение незаметно теряет строки
Чем UNION отличается от UNION ALL: неявный DISTINCT по всей строке, поведение NULL, цена сортировки, порядок и типы колонок, и как объединить события web и app, не потеряв больше половины строк.