NULL в SQL: подводные камни, которые ломают запросы
NULL — не ноль и не пустая строка: это отсутствие известного значения. Из-за этого обычные сравнения, фильтры и агрегаты дают неожиданный результат. Разбираем ловушки на примерах PostgreSQL 16 и показываем, что проверять в проде.

Запрос с условием WHERE status != 'done' не вернёт строки, где status равен NULL. Это не ошибка PostgreSQL: сравнение с неизвестным значением тоже даёт неизвестный результат, а фильтр пропускает только строки, для которых условие истинно. На такой мелочи теряются записи в отчётах, проверках доступа и фоновых задачах.
NULL меняет логику условий
Примеры ниже рассчитаны на PostgreSQL 16. В SQL выражение может быть истинным, ложным или неизвестным. NULL = NULL не истинно: результат — NULL. Поэтому условие WHERE column = NULL не находит пропуски. Для этого есть специальная проверка:
SELECT id
FROM orders
WHERE shipped_at IS NULL;
IS NULL и IS NOT NULL проверяют отсутствие значения. Это не то же самое, что сравнение с нулём, пустой строкой или специальным значением в приложении.
Если в запросе участвует NULL, сначала спросите себя не «равно ли значение чему-то?», а «что означает отсутствие значения именно в этой таблице?».
Трёхзначная логика1 — логика, в которой выражение может быть истинным, ложным или неизвестным. В SQL неизвестное значение обычно обозначают как NULL.
Ловушки в фильтрах и логических выражениях
Неравенство не включает пропуски
Допустим, вы хотите получить все заказы, статус которых не равен 'cancelled'. Условие status <> 'cancelled' исключит и отменённые заказы, и строки с NULL: для них результат сравнения неизвестен, а не истинен. Если пропуски тоже должны попасть в выборку, выразите это явно:
SELECT id
FROM orders
WHERE status <> 'cancelled'
OR status IS NULL;
Сначала договоритесь о смысле пропуска. Например, NULL может значить «статус ещё не назначен», а может — «данные не импортировались». Если это разные состояния, одного NULL недостаточно: нужны отдельные значения или дополнительный признак.
NOT IN может убрать все строки
Опасный случай — список для NOT IN, в котором есть NULL. Например, если подзапрос вернул значения 2 и NULL, проверка «значение не входит в этот набор» для большинства кандидатов станет неизвестной. В результате запрос способен вернуть пустой результат, хотя ожидается «всё, кроме совпавших строк».
Перед использованием NOT IN проверьте, гарантирует ли подзапрос отсутствие NULL. Если гарантия есть, закрепите её условием IS NOT NULL. Для проверки отсутствия связанной записи часто понятнее использовать NOT EXISTS:
SELECT u.id
FROM users AS u
WHERE NOT EXISTS (
SELECT 1
FROM blocked_users AS b
WHERE b.user_id = u.id
);
Этот вариант проверяет наличие совпадающей строки, а не принадлежность к списку с потенциальным неизвестным элементом. Но он не решает вопрос бизнес-смысла: если u.id сам допускает NULL, это тоже нужно учитывать.
JOIN, агрегаты и уникальность
Условие соединения не совпадает с NULL
В обычном соединении ON a.key = b.key строки с NULL в ключе друг с другом не соединятся: равенство неизвестно. Поэтому после LEFT JOIN NULL в колонках правой таблицы может означать как отсутствие пары, так и NULL в самой найденной записи — если соединение устроено иначе. Для проверки факта совпадения выбирайте колонку правой таблицы, которая объявлена NOT NULL, например первичный ключ.
В PostgreSQL есть оператор сравнения IS NOT DISTINCT FROM: он считает два NULL равными для сравнения. Это может быть полезно, когда совпадение двух пропусков действительно соответствует задаче. Не подменяйте им обычное равенство автоматически: так можно соединить множество строк с NULL между собой и резко увеличить результат.
COUNT считает не всё одинаково
COUNT(*) считает строки, а COUNT(column) — только строки, где указанная колонка не NULL. Поэтому эти выражения могут дать разные числа. Функции вроде SUM и AVG пропускают NULL-значения; если подходящих значений нет, результат может быть NULL, а не ноль.
Обёртка COALESCE(SUM(amount), 0) заменит NULL на ноль, но только если это корректно для отчёта. «Не было ни одной суммы» и «сумма равна нулю» — разные состояния. Смешав их, вы можете скрыть отсутствие данных.
UNIQUE и несколько пропусков
В PostgreSQL 16 ограничение UNIQUE по умолчанию допускает несколько строк с NULL в уникальном столбце: NULL не считается равным другому NULL для проверки уникальности. Это отличается от ожидания «уникальное поле заполнено или отсутствует только в одной записи». Если требуется не более одного NULL, в PostgreSQL есть вариант ограничения с NULLS NOT DISTINCT; проверьте его доступность и синтаксис для своей версии. Альтернатива — отдельный частичный уникальный индекс для строк с пропуском, если он соответствует модели данных.
| Ситуация | Что часто предполагают | Что проверить |
|---|---|---|
column = NULL | Найдёт пропуски | Использовать IS NULL |
column <> value | Вернёт всё, кроме совпадений | Добавить OR column IS NULL, если пропуски нужны |
NOT IN (subquery) | Исключит найденные значения | Проверить, не возвращает ли подзапрос NULL |
COUNT(column) | Посчитает строки | Он считает только непустые значения; для строк есть COUNT(*) |
UNIQUE(column) | Запретит повторный NULL | В PostgreSQL 16 по умолчанию допускает несколько NULL |
Как найти проблему до релиза
Универсального замера, который покажет цену NULL, нет: она зависит от схемы, плана запроса, объёма данных и распределения пропусков. Проверяйте конкретные запросы и сравнивайте результаты на данных, похожих на продакшен. Не подставляйте искусственный набор без NULL: именно он маскирует такие ошибки.
- Зафиксируйте смысл пропуска. Для каждой nullable-колонки запишите, что означает NULL: неизвестное, неприменимое или ещё не заполненное значение. Проверка: команда и код используют это значение одинаково.
- Посчитайте пропуски. Сравните число строк и число заполненных значений, например через
COUNT(*)иCOUNT(status). Проверка: доля NULL объяснима и не расходится с ожиданиями владельца данных. - Проверьте граничные случаи запросов. Добавьте в тестовые данные NULL, совпадение и отсутствие совпадения для соединений и фильтров. Проверка: тест отдельно фиксирует ожидаемый результат для каждой ситуации.
- Перепроверьте ограничения. Уточните, допускают ли схема и приложение NULL там, где поле участвует в ключе или бизнес-правиле. Проверка: попытка записать недопустимое состояние завершается ожидаемым отказом, а не тихо попадает в базу.
- Сравните план и результат на целевых данных. В PostgreSQL используйте
EXPLAINдля анализа плана, а фактическое выполнение включайте только там, где допустимо запускать запрос. Проверка: результат запроса корректен, а изменение не ухудшило время и нагрузку относительно зафиксированной базовой точки.
Где подход ломается
Явная обработка NULL делает запрос понятнее, но не исправляет плохую модель данных. Если поле по смыслу обязательно, лучше запретить пропуск ограничением, чем дописывать OR column IS NULL во все запросы. Если разные причины пропуска важны для отчётности, храните их отдельно. Иначе агрегаты и фильтры не смогут восстановить информацию, которую схема уже потеряла.
Особенно внимательно проверяйте код приложения: драйверы и ORM обычно преобразуют SQL NULL в значение языка программирования, но правила и типы зависят от конкретного инструмента. Сравнение с null в приложении не делает SQL-условие правильным — важен запрос, который реально ушёл в базу.
Что делать дальше
Выберите один отчёт или критичный запрос и составьте для него таблицу случаев: значение задано, значение NULL, совпадение не найдено. Зафиксируйте ожидаемые строки, добавьте эти случаи в тесты и отдельно проверьте план на продакшен-подобных данных. Инвалидация кеша и выбор стратегии индексации — отдельные темы; здесь важно сначала добиться правильной семантики результата.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: PostgreSQL 16: функции и операторы сравнения, PostgreSQL 16: агрегатные функции
Частые вопросы
Почему WHERE column = NULL ничего не возвращает?
Что выбрать: NOT IN или NOT EXISTS?
Можно ли заменить NULL на ноль через COALESCE?
Разрешает ли UNIQUE несколько NULL в PostgreSQL?
Если у меня другая версия PostgreSQL, примеры сработают?
Чем опасен NULL в проде, если тесты проходят?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.