Зачем углубляться в NULL
В модуле 2 мы установили базовое правило: NULL = NULL возвращает не TRUE, а сам NULL. Это следствие семантики «значение неизвестно». Но из этого простого утверждения вырастает целое дерево неочевидных следствий, и каждый второй баг в продовых запросах — это нарушение трёхзначной логики.
В этом уроке мы пройдём её до конца: посмотрим таблицы истинности для AND, OR, NOT, разберём классическую ловушку WHERE NOT IN (..., NULL), и познакомимся с операторами, специально сделанными, чтобы трактовать NULL как обычное значение.
Три значения вместо двух
Классическая (булева) логика — двухзначная: TRUE или FALSE. SQL-логика трёхзначная: TRUE, FALSE, UNKNOWN (он же NULL в контексте предикатов).
Таблица истинности AND
| A \ B | TRUE | FALSE | NULL |
|---|---|---|---|
| TRUE | TRUE | FALSE | NULL |
| FALSE | FALSE | FALSE | FALSE |
| NULL | NULL | FALSE | NULL |
Запомни мнемонику: AND с FALSE всегда FALSE (потому что для AND достаточно одного FALSE). Это работает даже с NULL: NULL AND FALSE = FALSE, потому что «что-то неизвестное И ложь» — это всё равно ложь.
Таблица истинности OR
| A \ B | TRUE | FALSE | NULL |
|---|---|---|---|
| TRUE | TRUE | TRUE | TRUE |
| FALSE | TRUE | FALSE | NULL |
| NULL | TRUE | NULL | NULL |
Симметрично: OR с TRUE всегда TRUE. NULL OR TRUE = TRUE, потому что «что-то неизвестное ИЛИ истина» — это уже истина.
NOT
NOT TRUE = FALSE, NOT FALSE = TRUE, NOT NULL = NULL. Отрицание неизвестного — неизвестное.
Проверь таблицы истинности на практике:
true_and_null — NULL. false_and_null — FALSE (это «короткое замыкание» AND с FALSE). true_or_null — TRUE. И так далее.
Как работает WHERE
WHERE оставляет в результате только те строки, для которых предикат равен TRUE. FALSE и NULL — обе отсеиваются. Это ключ к пониманию всех NULL-багов.
Только TRUE проходит фильтр. FALSE и NULL — обе уходят в корзину.
Из этого следует
WHERE x = 5 и WHERE x != 5 вместе не покрывают всех строк. Строки с x IS NULL пропадают в обоих случаях.
Проверь: birth_year = 1998 + birth_year != 1998 не дают всех клиентов:
Сумма eq + neq + is_null равна total, а вот eq + neq — нет. У Ирины birth_year = NULL, и она не попадает ни в один из двух фильтров. Чтобы получить «всё кроме 1998 года рождения», нужно явно: WHERE birth_year != 1998 OR birth_year IS NULL.
Классическая ловушка: NOT IN с NULL
Это самая частая ошибка в продовых запросах. Если в списке для NOT IN есть хотя бы один NULL, результат становится пустым.
Магия NOT IN с NULL — внимательно посмотри на оба запроса:
Почему? Потому что x NOT IN (a, b, c) разворачивается в x != a AND x != b AND x != c. Если один из этих сравнений — x != NULL, он всегда NULL. А что-угодно AND NULL — либо FALSE, либо NULL. Никогда TRUE. Значит, WHERE ничего не пропускает.
Это особенно коварно, когда NULL приходит не из литерала, а из под-запроса:
SELECT * FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders); -- если в orders есть NULL в customer_id, всё ломается
Решение — использовать NOT EXISTS или IS DISTINCT FROM. Они трактуют NULL по-другому.
IS DISTINCT FROM — оператор «равенство, но как у нормальных людей»
IS DISTINCT FROM — это специальный оператор, который сравнивает значения, считая NULL обычным значением. Два NULL для него равны.
| Сравнение | = | IS DISTINCT FROM |
|---|---|---|
5 = 5 | TRUE | FALSE |
5 = 7 | FALSE | TRUE |
5 = NULL | NULL | TRUE |
NULL = NULL | NULL | FALSE |
Обратный — IS NOT DISTINCT FROM, который ведёт себя как «равенство с NULL = NULL».
Сравни поведение = и IS DISTINCT FROM:
Когда полезно? Когда сравниваешь два значения, и оба могут быть NULL, и тебе нужно понимать «они одинаковые». Типичный случай — UPDATE-trigger или сравнение «было vs стало» в audit log:
-- "Изменилось ли значение?"
SELECT * FROM audit
WHERE old_value IS DISTINCT FROM new_value; -- работает корректно с любыми NULL
С обычным != строки, где old_value IS NULL и new_value IS NULL, неправильно считались бы «изменившимися» (потому что NULL != NULL = NULL = не TRUE = отсеивается) — то есть, наоборот, неизменными.
Исключения: set-операции, GROUP BY, DISTINCT
Есть узкие места, где SQL отступает от трёхзначной логики и считает NULL равным сам себе:
UNION,INTERSECT,EXCEPT— при устранении дубликатов NULL считается равным NULL.GROUP BY— две строки с NULL в группирующем столбце попадают в одну группу.DISTINCT— несколько NULL свернутся в один.- Уникальные индексы — : до Postgres 15 несколько NULL в уникальной колонке разрешались (NULL != NULL), теперь есть опциятут наоборот
NULLS NOT DISTINCT.
GROUP BY и DISTINCT сворачивают NULL в одну группу:
Две строки с NULL группируются вместе — cnt = 2. Если бы NULL считался уникальным, было бы две группы по одному NULL.
NULL в JOIN — где сюрприз
INNER JOIN ON a.x = b.y отсеивает строки, где a.x или b.y равны NULL — потому что предикат = NULL это NULL, а JOIN ON оставляет только TRUE. Это редко бывает желаемым поведением для «опциональных» внешних ключей.
LEFT JOIN сохраняет левые строки, и NULL не отсеивает — но он создаёт новые NULL для правой части, когда совпадения не нашлось. После такого JOIN всегда внимательно с WHERE: WHERE b.x IS NULL — стандартный паттерн для anti-join.
Anti-join: клиенты без заказов через LEFT JOIN + IS NULL:
В нашей вселенной таких может не быть (плотные seed-данные) — но синтаксис теперь у тебя в руках.
Чек-лист
- SQL-логика трёхзначная:
TRUE,FALSE,NULL. Предикат может вернуть любой из трёх. WHEREоставляет только строки с предикатомTRUE.FALSEиNULLотсеиваются вместе.x = 5иx != 5не покрывают строки сx IS NULL. Нужен явныйOR x IS NULL.NOT IN (..., NULL)всегда даёт пустой результат — классическая ловушка с подзапросами.IS DISTINCT FROM— равенство, в котором NULL считается обычным значением.- В
UNION,GROUP BY,DISTINCTNULL равен сам себе (исключение из общей логики). INNER JOIN ON a.x = b.yотбрасывает строки с NULL в ключе соединения.