Все материалы
Продуктовая аналитикапрактикумсредний

Операторы сравнения и логика в SQL: AND, OR, NOT и NULL

Трёхзначная логика SQL на практике: почему NOT IN возвращает пустоту, где AND незаметно съедает OR и как собрать длинный фильтр так, чтобы его можно было проверить.

КПКейсПрактика4 августа 2026 г.13 мин

Маркетинг просит выгрузку: пользователи из России, на мобильных или планшетах, без тестовых аккаунтов. Запрос написан за минуту, число получено, рассылка ушла. Через день выясняется, что в выборку попали пользователи из других стран, а половина планшетов, наоборот, потерялась. Ошибки в фильтрах почти никогда не выглядят как ошибки: запрос выполняется, число возвращается, и выглядит оно правдоподобно. Причина обычно в двух вещах — приоритете операторов и в том, что в 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 может не покрыть ничего.

Что происходит с NULL в сравнении
ВыражениеРезультатСтрока попадёт в WHERE?
country = 'RU' при country = NULLUNKNOWNнет
country <> 'RU' при country = NULLUNKNOWNнет
NOT (country = 'RU') при country = NULLUNKNOWNнет
country IS NULLTRUEда
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, а не считаются заполненными значениями?
  • Длинный фильтр разложен на именованные флаги, а падение аудитории по шагам объяснимо?
Продолжить чтение
Вся библиотека