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

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

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

КейсПрактика4 августа 2026 г.обновлено 2 октября 2026 г.12 мин

Маркетинг просит выгрузку: пользователи из России, на мобильных или планшетах, без тестовых аккаунтов. Запрос написан за минуту, число получено, рассылка ушла. Через день выясняется, что в выборку попали пользователи из других стран, а половина планшетов, наоборот, потерялась. Ошибки в фильтрах почти никогда не выглядят как ошибки: запрос выполняется, число возвращается, и выглядит оно правдоподобно. Причина обычно в двух вещах — приоритете операторов и в том, что в 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' и получает 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 < endBETWEEN включает правую границу и срезает последний день
есть ли связанная строкаEXISTSJOIN вместо 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, а не считаются заполненными значениями?
  • Длинный фильтр разложен на именованные флаги, а падение аудитории по шагам объяснимо?
Продолжить чтение
Вся библиотека
Продуктовая аналитика30 июля 2026 г.19 мин
Три вложенных прямоугольника, внутренний закрашен.

Подзапросы в SQL: IN, EXISTS, подзапрос в FROM и когда нужен JOIN или CTE

Подзапросы в SQL на реальной учебной базе: IN и EXISTS вместо JOIN, почему NOT IN возвращает ноль строк из-за NULL, подзапрос в FROM для смены зерна, скалярный и коррелированный подзапрос, подзапрос в HAVING и правило выбора между подзапросом, JOIN и CTE.

Читать материал
Продуктовая аналитика29 июля 2026 г.17 мин

UNION и UNION ALL в SQL: где объединение незаметно теряет строки

Чем UNION отличается от UNION ALL: неявный DISTINCT по всей строке, поведение NULL, цена сортировки, порядок и типы колонок, и как объединить события web и app, не потеряв больше половины строк.

Читать материал