Привет, Хабр!

В SQL есть отдельный класс ошибок, неприятных именно тем, что они никак себя не проявляют: база не выдаёт исключения, не пишет предупреждения — запрос просто возвращает не то, что нужно. Чаще всего это пустой результат там, где вы ожидаете десятки строк. Один из самых распространённых случаев — NOT IN в сочетании с NULL.

Возьмём задачу: найти сотрудников, которые никем не руководят. В таблице employees есть поле manager_id — ссылка на начальника. Рассуждение простое: руководитель — это тот, кто хотя бы раз встречается в manager_id, а значит, не руководители — все, кого там нет. Запрос пишется почти сам собой:

SELECT name
FROM employees
WHERE id NOT IN (SELECT manager_id FROM employees);

Вы запускаете его и получаете ноль строк, хотя точно знаете: обычных сотрудников в компании большинство, и в выборку они обязаны попасть.

Логично заподозрить данные или сам запрос и для проверки заменить NOT IN на IN — посмотреть хотя бы на руководителей. IN отрабатывает корректно. А NOT IN на тех же данных упрямо возвращает пустоту. Исключения при этом нет, синтаксис безупречен, поэтому причину найти непросто.

Дальше разберём, почему NOT IN ломается из‑за одного‑единственного NULL, почему IN рядом продолжает работать корректно и почему NOT IN, вопреки названию, не противоположность IN.

Пример

Пять сотрудников, одна простая иерархия:

CREATE TABLE employees (
    id         int PRIMARY KEY,
    name       text,
    manager_id int
);

INSERT INTO employees VALUES
    (1, 'Алексей', NULL),   -- директор, начальника нет
    (2, 'Борис',   1),
    (3, 'Вера',    1),
    (4, 'Глеб',    2),
    (5, 'Дина',    2);

Кто здесь руководитель? Тот, на кого ссылаются. В колонке manager_id встречаются 1 и 2 — значит, руководят Алексей и Борис. Остальные трое — Вера, Глеб и Дина — никем не командуют. Их мы и хотим получить.

SELECT name FROM employees
WHERE id NOT IN (SELECT manager_id FROM employees);
 name
------
(0 rows)

Ноль. Не Вера с Глебом и Диной, а пусто. Притом что IN на тех же данных отрабатывает безупречно:

SELECT name FROM employees
WHERE id IN (SELECT manager_id FROM employees);
 name
---------
 Алексей
 Борис
(2 rows)

Разворачиваем NOT IN

Подзапрос SELECT manager_id FROM employees возвращает набор значений: NULL, 1, 1, 2, 2. Тот самый NULL — от Алексея, у которого начальника нет.

Теперь развернём NOT IN в то, чем он работает под капотом. x IN (a, b, c) — это сокращение для x = a OR x = b OR x = c. Соответственно, x NOT IN (a, b, c) — это NOT (x = a OR x = b OR x = c), по законам логики — x <> a AND x <> b AND x <> c.

Цепочка неравенств через AND. Подставим наш набор и проверим Веру, у неё id = 3:

3 <> NULL   AND   3 <> 1   AND   3 <> 2

3 <> 1 — это TRUE. 3 <> 2 — тоже TRUE. А 3 <> NULL?

В SQL NULL — не значение, а неизвестно. Сравнить с ним нельзя ничего, и 3 <> NULL не вернёт ни TRUE, ни FALSE — только UNKNOWN, третий результат трёхзначной логики.

Складываем цепочку: TRUE AND TRUE AND UNKNOWN. И весь результат — UNKNOWN.

А WHERE пропускает дальше только строгое TRUE. UNKNOWN он выбрасывает наравне с FALSE. Вера вылетает. Глеб и Дина — по той же схеме: у них в цепочке такой же <> NULL, и каждая цепочка снова сваливается в UNKNOWN. Вылетают все.

Заметьте, настоящие руководители — Алексей и Борис — отсеялись бы и так, и это честно: у Алексея в цепочке есть 1 <> 1, это FALSE, а FALSE AND что угодно даёт FALSE. А вот не‑руководители, которые должны были пройти, спотыкаются именно об UNKNOWN. Один NULL в подзапросе сделал так, что NOT IN не способен вернуть TRUE ни для кого. Отсюда и ноль строк.

И подзапрос для этого не нужен — достаточно списка вручную:

SELECT name FROM employees WHERE id NOT IN (3, 4, NULL);  -- 0 строк

Один NULL в скобках — и весь NOT IN отравлен.

Самое скверное тут даже не в логике, а в поведении со стороны.

База не падает, ошибку не кидает. С её точки зрения всё стерильно: трёхзначная логика отработала строго по стандарту. Запрос вернул результат — просто пустой или подозрительно куцый. А это глазами ловится в разы хуже, чем честный ERROR в консоли. И ведёт себя так не только PostgreSQL — это поведение стандарта SQL.

NULL в таких колонках — норма. У директора нет начальника. У товара может не быть категории, у события — связанного заказа. NULL в manager_id не баг, который кто‑то однажды вычистит. Это валидные данные, они там навсегда. Хватит одной такой строки на миллион — и любой NOT IN по этой колонке отравлен.

Чем лечить: NOT EXISTS

Надёжное лекарство — NOT EXISTS с коррелированным подзапросом:

SELECT e.name
FROM employees e
WHERE NOT EXISTS (
    SELECT 1
    FROM employees sub
    WHERE sub.manager_id = e.id
);
 name
------
 Вера
 Глеб
 Дина
(3 rows)

Те самые трое. EXISTS устроен иначе, в этом вся соль, он не сравнивает значения, а отвечает на один вопрос — нашлась хоть одна строка или нет. Ответ двузначный, да или нет, третьего не дано. NULL внутри подзапроса ему не страшен: строка Алексея с manager_id = NULL просто не совпадёт с e.id в условии sub.manager_id = e.id и не попадёт в подсчёт. Никакого UNKNOWN, который полез бы наружу.

Второй крепкий вариант — антиджойн через LEFT JOIN:

SELECT e.name
FROM employees e
LEFT JOIN employees sub ON sub.manager_id = e.id
WHERE sub.id IS NULL;

Тоже отдаёт Веру, Глеба и Дину, тоже не боится NULL.

Можно ли спасти сам NOT IN? Можно — выкинуть NULL из подзапроса руками:

SELECT name FROM employees
WHERE id NOT IN (
    SELECT manager_id FROM employees WHERE manager_id IS NOT NULL
);

Сработает, но это костыль на честном слове: держится на том, что вы помните про NULL и не забыли дописать фильтр. В следующий раз забудете и снова получите тихую пустоту. NOT EXISTS помнить ничего не просит.

Как поймать это у себя

Подозреваете NOT IN по конкретной колонке — спросите базу, есть ли там NULL:

SELECT count(*) FROM employees WHERE manager_id IS NULL;

Ноль — NOT IN по этой колонке пока безопасен. Больше нуля — он уже отравлен, и результат запроса вам врёт.

Также стоит пройтись поиском по NOT IN (SELECT — почти каждое такое место кандидат на разбор. Колонка в подзапросе объявлена как NOT NULL? Тогда NOT IN безопасен, его можно оставить. Колонка NULL — переписывайте на NOT EXISTS, не дожидаясь, пока туда заедет первый NULL.

NOT IN — это не «IN наоборот»

Остался вопрос с начала: почему IN выжил, а NOT IN рухнул на том же NULL?

Потому что они по‑разному дружат с UNKNOWN. IN раскрывается в цепочку OR. Для руководителя это TRUE OR ... OR UNKNOWN — а OR хватает одного TRUE, чтобы выдать TRUE, остальное не важно. UNKNOWN в цепочке OR просто тонет.

NOT IN раскрывается в цепочку AND. А AND к UNKNOWN беспощаден: одного UNKNOWN достаточно, чтобы увести весь результат из TRUE в UNKNOWN. Та же NULL‑строка, которую OR не заметил, для AND оказывается ядом.

Эта несимметрия и подводит. IN работает — и кажется само собой разумеющимся, что NOT IN просто его зеркало. Не зеркало.

Вернёмся к нулю строк из начала. Ваши не‑руководители никуда не пропадали, их отравил единственный NULL в manager_id, переведя условие из TRUE в UNKNOWN, и WHERE молча вынес всех за дверь.

В итоге NOT IN — не противоположность IN, как нашёптывает название. Под трёхзначной логикой это две разные операции с разным характером, и NULL ломает только одну. Рабочее правило короткое: исключаете что‑то по подзапросу — берите NOT EXISTS по умолчанию, не задумываясь. А NOT IN оставьте для захардкоженных списков, про которые точно знаете — NULL там нет и не будет.

В статье мы разобрали, как один NULL может превратить корректный на вид NOT IN в тихую логическую ловушку: запрос не падает, но возвращает не тот результат. Если хотите увереннее работать со сложной SQL‑логикой и лучше понимать, почему база ведёт себя именно так, обратите внимание на два бесплатных открытых урока OTUS:

Больше бесплатных открытых уроков по аналитике, базам данных и не только можно найти в календаре мероприятий OTUS.

Комментарии (18)


  1. Akina
    20.05.2026 10:07

    Теперь развернём NOT IN в то, чем он работает под капотом. x IN (a, b, c) — это сокращение для x = a OR x = b OR x = c. Соответственно, x NOT IN (a, b, c) — это NOT (x = a OR x = b OR x = c), по законам логики — x <> a AND x <> b AND x <> c.

    Предлагаю изменить форматирование и сделать вот это вот всё крупным, ярким и ну очень выделенным, чтобы сразу бросалось в глаза. Может быть, даже оформить это как отдельный раздел, а не как рядовой фрагмент внутри раздела. Потому что именно это - объяснение, а всё остальное - просто антураж.


  1. daregod
    20.05.2026 10:07

    Вместо WHERE можно оборачивать (но вообще полезно всегда помнить, что поле nullable).

    SELECT name FROM employees WHERE id NOT IN (SELECT COALESCE(manager_id,0) FROM employees);


  1. ptr128
    20.05.2026 10:07

    • Я бы начинал с того, что not manager_id=id тоже не инвертирование условия при наличии NULL. Хотя ошибку чаще допускают, ставя NOT перед скобками с более сложным логическим выражением.


  1. SabMakc
    20.05.2026 10:07

    Когда-то работал в компании, где приняли очень грамотное решение. А именно: отказаться от NULL в таблицах вообще. Вместо NULL было 0-е значение. Да, пришлось в таблицы добавлять нулевые строки, но зато и с подобными приколами не сталкивались. Работы с SQL было много, так что решение однозначно окупило себя.


    1. ptr128
      20.05.2026 10:07

      В некоторых случаях такой подход может приводить к увеличению таблицы в разы. К тому же он всё равно не решает проблемы, так как NULL в поле запроса может возникать не только из фактического значения поля в таблице.


      1. SabMakc
        20.05.2026 10:07

        Да, может возникать NULL - но как часто? Я навскидку не припомню, чтобы были проблемы из-за этого, хотя запросов я писал очень много.

        Про увеличение таблиц в разы - можно подробнее? Может в каких-то особых случаях, но на вскидку даже индексы можно сделать с исключением 0-го значения, если понадобится.


        1. Akina
          20.05.2026 10:07

          Да, может возникать NULL - но как часто?

          Аргументом NOT IN чаще является подзапрос, а не список значений. Этот подзапрос может иметь источником несколько таблиц с OUTER JOIN - вот вам и потенция для NULL.


          1. SabMakc
            20.05.2026 10:07

            Если у тебя все ссылки идут с 0 вместо NULL - то у тебя нет необходимости в чем-либо, кроме простого JOIN (он же INNER JOIN). LEFT/RIGHT JOIN еще встречались, но скорее в формате “JOIN для подзапроса”.


        1. ptr128
          20.05.2026 10:07

          Кроме явных случаев с OUTER JOIN, NULL могут возвращать функции. В том числе и агрегатные.

          Что касается размеров кортежа, то в PostgreSQL NULL занимает в кортеже один бит, integer - 32 бита, 0::decimal - 3+2 байта = 40 бит. То есть, если в кортеже из восьми integer полей семь NULL, то его общая длина будет 23+1+4=28 байт. А если все восемь ноль - то 23+1+4*8=56 байт. В случае пары десятков decimal или varchar - можете сами посчитать, учитывая, что NULL-mask требует по байту на каждые восемь nullable полей.


          1. SabMakc
            20.05.2026 10:07

            Про JOIN ответил выше. Функции - аналогично, как часто результаты агрегатных функций попадают в условия? Да ISNULL(..., 0) или COALESCE(...,0) в помощь при необходимости.

            Я не думаю, что “таблица из восьми полей, все могут быть NULL” - очень частая история. Обычно поля все-таки заполняются значениями, а NULL для ссылок идет. Да, места может потребоваться больше. Но не в разы (в разы - очень частная история). Но есть и выигрыш в производительности от фиксированного размера кортежа - не стоит про это забывать.


            1. ptr128
              20.05.2026 10:07

              Про JOIN ответил выше

              Так себе ответ. OUTER JOIN применяются не редко. Просто потому, в таблице операций может не быть ссылок на все элементы справочника, который в запросе фильтруется по каким-то полям. А отсутствие записей - это тоже результат.

              Для примера, в одном из моих текущих проектов по логистике вообще нет nullable PK. При этом там INNER встречается 38 тысяч раз, а OUTER - 18 тысяч раз. То есть, каждый третий JOIN - OUTER.

              как часто результаты агрегатных функций попадают в условия?

              Да повсеместно. Хоть SUM по нулевому количеству записей или LEAD/LAG на первой или последней записи в PARTITION.

              Я не думаю, что “таблица из восьми полей, все могут быть NULL” - очень частая история.

              На основании чего такие мысли? Предлагаю взять любую ERP на Ваш выбор и я в ней укажу сходу десятки таких таблиц.

              Но есть и выигрыш в производительности от фиксированного размера кортежа - не стоит про это забывать.

              Впервые такое слышу. Вот от уменьшения размера кортежа, благодаря которому на одной странице умещается больше кортежей - выигрыш в производительности показать могу легко. Только до компа добраться надо. А сможете ли Вы показать тут повторяемый пример, доказывающий Ваше утверждение?


              1. SabMakc
                20.05.2026 10:07

                Просто потому, в таблице операций может не быть ссылок на все элементы справочника, который в запросе фильтруется по каким-то полям. А отсутствие записей - это тоже результат.

                Перепутал OUTER JOIN с CROSS JOIN. LEFT/RIGHT JOIN (OUTER опционален) - да, используются. Но заметно реже. “нет nullable PK” - а как иначе? Я про “нет nullable FK”. Сколько бы было у вас OUTER JOIN если бы не NULL в FK?

                Да, если идет агрегация данных, например “справочник с количеством ссылающихся операций” - тогда да, LEFT JOIN нужен. Но так ли много подобных запросов?

                На основании чего такие мысли? Предлагаю взять любую ERP на Ваш выбор и я в ней укажу сходу десятки таких таблиц.

                На основании опыта. Таблица с одними числами, причем опциональными числами - не припомню. И тем более в разрезе “заметно экономим на nullable-полях”. Максиму кросс-таблицы приходят на ум, но там 3 числа (id, FK_1, FK_2, может еще какое поле) и все они по идее обязательны (а иначе и в самой записи нет смысла). А размер такой таблицы будет меньше с NOT NULL на FK-полях. Впрочем, с нулевыми ссылками (вместо NULL) таблица тоже мало имеет смысла из тех же соображений.

                Впервые такое слышу. Вот от уменьшения размера кортежа, благодаря которому на одной странице умещается больше кортежей - выигрыш в производительности показать могу легко.

                Предсказуемая адресация строк и колонок - чем не плюс? Найти искусственный пример, думаю, смогу. Другой вопрос, как это будет соотноситься с реальной жизнью. Думаю примерно также, как и “все в NULL” из соображений “в разы меньше хранить придется”.

                P.S. я не спорю, что в большинстве своем nullable-поля экономичнее на диске будут. Я спорю именно с “может приводить к увеличению таблицы в разы” - если и будет такой сценарий, то крайне редко и в очень специфичных условиях. А минимизация ошибок в запросах лично для меня в приоритете. Именно из-за скрытого характера описанных в статье проблем.


                1. ptr128
                  20.05.2026 10:07

                  Сколько бы было у вас OUTER JOIN если бы не NULL в FK?

                  Столько же. Ведь выборка из таблиц операций идет почти всегда от справочников по соответствующим индексам. Что логично, так как в обратном случае мог работать разве что индекс по датам.

                  Да, если идет агрегация данных, например “справочник с количеством ссылающихся операций” - тогда да, LEFT JOIN нужен. Но так ли много подобных запросов?

                  Почти в каждом отчете.

                  Найти искусственный пример,

                  Зачем искать? Так сложно самому написать скрипт примера?

                  Я спорю именно с “может приводить к увеличению таблицы в разы”

                  Какой смысл спорить, если я уже предложил решение этого спора? Называйте ERP и там увидим. Хоть SAP, хоть OEBS, хоть Dynamics.

                  А минимизация ошибок в запросах лично для меня в приоритете.

                  Из этого лишь следует, что у Вас крошечные объёмы данных. Иначе бы производительность была бы существенно важнее.


                  1. SabMakc
                    20.05.2026 10:07

                    Вот так и не понял, где в “Ведь выборка из таблиц операций идет почти всегда от справочников по соответствующим индексам” возникает LEFT JOIN. Если фильтр по справочнику - то там не “LEFT JOIN”, а просто условие в WHERE.

                    Если в таблице операций показываются значения из справочника (а не просто ID записи) - то “нет nullable FK” спасет от LEFT JOIN.

                    Искать затем, что “когда-то я работал в компании” - это 10+ лет. Сейчас я с SQL работаю сильно меньше и примера готового все равно не имею.

                    Попросил ИИ подобрать пример - и он справился, с 1й попытки. 2 таблицы, по 10 int-полей в каждой. Одна - nullable, другая с нулями. Фиксированная таблица на 30% быстрее оказалась в работе. Разница в размере - 30% при 10% заполнении данными в пользу nullable и менее 5% при 70% заполнении.

                    портянка кода с комментариями

                    ИИ немного напутал в размерах в своих выводах (сказал, что преимущество у fixed в размере), но все равно показательно, особенно в разрезе “разница в разы”:

                    Тестировал в PostgreSQL, что был под рукой. 15й, без какой-либо настройки (просто запущенный инстанс в docker).

                    -- Создадим две таблицы по 1 млн строк с десятью числовыми столбцами.
                    -- В первой NULL разрешён и активно используется. Во второй — все столбцы NOT NULL, а вместо NULL записан 0.
                    
                    -- Таблица с NULL (переменная длина кортежа)
                    CREATE TABLE metrics_nullable (
                        id        int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
                        day       date NOT NULL,
                        value1    int,
                        value2    int,
                        value3    int,
                        value4    int,
                        value5    int,
                        value6    int,
                        value7    int,
                        value8    int,
                        value9    int,
                        value10   int
                    );
                    
                    -- Таблица с 0 вместо NULL, все столбцы NOT NULL (фиксированная длина кортежа)
                    CREATE TABLE metrics_fixed (
                        id        int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
                        day       date NOT NULL,
                        value1    int NOT NULL DEFAULT 0,
                        value2    int NOT NULL DEFAULT 0,
                        value3    int NOT NULL DEFAULT 0,
                        value4    int NOT NULL DEFAULT 0,
                        value5    int NOT NULL DEFAULT 0,
                        value6    int NOT NULL DEFAULT 0,
                        value7    int NOT NULL DEFAULT 0,
                        value8    int NOT NULL DEFAULT 0,
                        value9    int NOT NULL DEFAULT 0,
                        value10   int NOT NULL DEFAULT 0
                    );
                    
                    -- Заполняем таблицу с NULL (70% шанс заполнения)
                    INSERT INTO metrics_nullable (day, value1, value2, value3, value4, value5,
                                                  value6, value7, value8, value9, value10)
                    SELECT
                        current_date - (i % 365),
                        CASE WHEN random() < 0.7 THEN (random()*1000)::int END,
                        CASE WHEN random() < 0.7 THEN (random()*1000)::int END,
                        CASE WHEN random() < 0.7 THEN (random()*1000)::int END,
                        CASE WHEN random() < 0.7 THEN (random()*1000)::int END,
                        CASE WHEN random() < 0.7 THEN (random()*1000)::int END,
                        CASE WHEN random() < 0.7 THEN (random()*1000)::int END,
                        CASE WHEN random() < 0.7 THEN (random()*1000)::int END,
                        CASE WHEN random() < 0.7 THEN (random()*1000)::int END,
                        CASE WHEN random() < 0.7 THEN (random()*1000)::int END,
                        CASE WHEN random() < 0.7 THEN (random()*1000)::int END
                    FROM generate_series(1, 1000000) AS gs(i);
                    
                    -- Копируем во вторую таблицу, заменяя NULL на 0
                    INSERT INTO metrics_fixed (day, value1, value2, value3, value4, value5,
                                               value6, value7, value8, value9, value10)
                    SELECT
                        day,
                        COALESCE(value1, 0),
                        COALESCE(value2, 0),
                        COALESCE(value3, 0),
                        COALESCE(value4, 0),
                        COALESCE(value5, 0),
                        COALESCE(value6, 0),
                        COALESCE(value7, 0),
                        COALESCE(value8, 0),
                        COALESCE(value9, 0),
                        COALESCE(value10, 0)
                    FROM metrics_nullable;
                    
                    -- Сравнение размера на диске
                    SELECT
                        'nullable' AS table_type,
                        pg_size_pretty(pg_total_relation_size('metrics_nullable')) AS total_size
                    UNION ALL
                    SELECT
                        'fixed',
                        pg_size_pretty(pg_total_relation_size('metrics_fixed'));
                    
                    -- Сравнение скорости полного сканирования и агрегации
                    -- Выполним суммирование всех значений по всем столбцам — операция, которая читает каждую строку полностью.
                    EXPLAIN (ANALYZE, BUFFERS, TIMING)
                    SELECT
                        sum(value1), sum(value2), sum(value3), sum(value4), sum(value5),
                        sum(value6), sum(value7), sum(value8), sum(value9), sum(value10)
                    FROM metrics_nullable;
                    
                    EXPLAIN (ANALYZE, BUFFERS, TIMING)
                    SELECT
                        sum(value1), sum(value2), sum(value3), sum(value4), sum(value5),
                        sum(value6), sum(value7), sum(value8), sum(value9), sum(value10)
                    FROM metrics_fixed;
                    

                    При 70% заполнении разница в хранении nullable 92 MB vs fixed 95 MB - далеко не в разы, разница менее 5% (1млн записей). При 10% заполнении nullable 69 MB vs fixed 95 MB - ~30%, никак не “разы”.

                    Скорость же выполнения запроса к таблицам у меня примерно на 30% быстрее для варианта с fixed (и от заполнения 70%/10% не сильно изменилась). С ростом таблицы время растет линейно, пропорция не меняется.

                    Откуда пошли ERP-системы - вопрос интересный. Я не упоминал подобного. Я в другой сфере работал.

                    Объемы - сотни GB, под террабайт базы бывали. Может это и крошечные. Но как показал пример, разница там не сильно велика.

                    P.S. интересно, что если не использовать null в примере вообще (для nullable полей), то разницы и нет ни в размерах, ни в скорости работы. Уж не знаю, в чью сторону это аргумент )


                    1. ptr128
                      20.05.2026 10:07

                      Вот так и не понял

                      Объясняю, очень сильно упрощая. Есть справочник вагонов:

                      CREATE TABLE cars (
                        id integer PRIMARY KEY,
                        accure_date date NOT NULL, -- дата акта получения
                        dismiss_date date NULL, -- дата акта выбытия
                        max_mileage integer -- максимальный пробег до планового ремонта
                      );

                      Есть рейсы вагонов:

                      CREATE TABLE hauls (
                        car_id integer REFERENCES cars,
                        out_date datetimetz NOT NULL, -- дата отправления
                        haul_mileage integer NOT NULL -- пробег в рейсе
                      );

                      Задача - вывести для каждого вагона, доступного на текущую дату (между accure_date и dismiss_date) остаточный пробег до планового ремонта, как разницу между максимальным пробегом и пробегом во всех совершенных им рейсов с момента акта получения вагона. С учётом того, что в наличии есть вагоны, которые после покупки, взятия в аренду или получения их по доверенности ещё не совершали рейсов. И таких вагонов у крупных операторов ежедневно сотни.

                      Искать затем, что “когда-то я работал в компании” - это 10+ лет. Сейчас я с SQL работаю сильно меньше и примера готового все равно не имею.

                      Во-первых, сразу видим несоответствие между

                      Попросил ИИ подобрать пример

                      и

                      Объемы - сотни GB, под террабайт базы бывали.

                      Ну и, как следствие, у AI возникла галлюцинация и он вставил в таблицу metrics_nullable множество нулей, хотя их там быть не должно.

                      Для информации, у меня только одна таблица истории дислокаций вагонов больше двух терабайт. DWH же суммарно давно петабайт преодолел.

                      Во-вторых, добавлять сюда размер индексов некорректно, так как обсуждался размер кортежей.

                      Во-третьих, я писал "в некоторых случаях", из чего следует, что могу привести пример.

                      Пожалуйста
                      CREATE TABLE metrics_nullable (
                          id        int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
                          value1    decimal(18,2),
                          value2    decimal(18,2),
                          value3    decimal(18,2),
                          value4    decimal(18,2),
                          value5    decimal(18,2),
                          value6    decimal(18,2),
                          value7    decimal(18,2),
                          value8    decimal(18,2),
                          value9    decimal(18,2),
                          value10   decimal(18,2),
                          value11   decimal(18,2),
                          value12   decimal(18,2),
                          value13   decimal(18,2),
                          value14   decimal(18,2),
                          value15   decimal(18,2),
                          value16   decimal(18,2)
                      );
                      CREATE TABLE metrics_fixed (
                          id        int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
                          value1    decimal(18,2) NOT NULL DEFAULT 0,
                          value2    decimal(18,2) NOT NULL DEFAULT 0,
                          value3    decimal(18,2) NOT NULL DEFAULT 0,
                          value4    decimal(18,2) NOT NULL DEFAULT 0,
                          value5    decimal(18,2) NOT NULL DEFAULT 0,
                          value6    decimal(18,2) NOT NULL DEFAULT 0,
                          value7    decimal(18,2) NOT NULL DEFAULT 0,
                          value8    decimal(18,2) NOT NULL DEFAULT 0,
                          value9    decimal(18,2) NOT NULL DEFAULT 0,
                          value10   decimal(18,2) NOT NULL DEFAULT 0,
                          value11   decimal(18,2) NOT NULL DEFAULT 0,
                          value12   decimal(18,2) NOT NULL DEFAULT 0,
                          value13   decimal(18,2) NOT NULL DEFAULT 0,
                          value14   decimal(18,2) NOT NULL DEFAULT 0,
                          value15   decimal(18,2) NOT NULL DEFAULT 0,
                          value16   decimal(18,2) NOT NULL DEFAULT 0
                      );
                      INSERT INTO metrics_nullable (value1, value2, value3, value4, value5,
                        value6, value7, value8, value9, value10, value11, value12, value13,
                        value14, value15, value16)
                      SELECT
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END,
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END,
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END,
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END,
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END,
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END,
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END,
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END,
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END,
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END,
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END,
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END,
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END,
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END,
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END,
                          CASE WHEN random() < 0.01 THEN (random()*1000+1)::int END
                      FROM generate_series(1, 1000000) AS gs(i);
                      INSERT INTO metrics_fixed (value1, value2, value3, value4, value5,
                        value6, value7, value8, value9, value10, value11, value12, value13,
                        value14, value15, value16)
                      SELECT
                          COALESCE(value1, 0),
                          COALESCE(value2, 0),
                          COALESCE(value3, 0),
                          COALESCE(value4, 0),
                          COALESCE(value5, 0),
                          COALESCE(value6, 0),
                          COALESCE(value7, 0),
                          COALESCE(value8, 0),
                          COALESCE(value9, 0),
                          COALESCE(value10, 0),
                          COALESCE(value11, 0),
                          COALESCE(value12, 0),
                          COALESCE(value13, 0),
                          COALESCE(value14, 0),
                          COALESCE(value15, 0),
                          COALESCE(value16, 0)
                      FROM metrics_nullable;
                      SELECT 'nullable' AS table_type, pg_size_pretty(pg_relation_size('metrics_nullable')) AS total_size
                      UNION ALL
                      SELECT 'fixed', pg_size_pretty(pg_relation_size('metrics_fixed'));
                      
                      EXPLAIN ANALYZE
                      SELECT
                          sum(value1), sum(value2), sum(value3), sum(value4), sum(value5),
                          sum(value6), sum(value7), sum(value8), sum(value9), sum(value10),
                          sum(value11), sum(value12), sum(value13), sum(value14), sum(value15), sum(value16)
                      FROM metrics_nullable;
                      
                      Finalize Aggregate  (cost=27381.80..27381.81 rows=1 width=512) (actual time=79.031..80.915 rows=1.00 loops=1)
                        Buffers: shared hit=5548
                        ->  Gather  (cost=27381.39..27381.60 rows=2 width=512) (actual time=78.888..80.894 rows=3.00 loops=1)
                              Workers Planned: 2
                              Workers Launched: 2
                              Buffers: shared hit=5548
                              ->  Partial Aggregate  (cost=26381.39..26381.40 rows=1 width=512) (actual time=75.819..75.820 rows=1.00 loops=3)
                                    Buffers: shared hit=5548
                                    ->  Parallel Seq Scan on metrics_nullable  (cost=0.00..9714.67 rows=416667 width=80) (actual time=0.004..13.483 rows=333333.33 loops=3)
                                          Buffers: shared hit=5548
                      Planning Time: 0.091 ms
                      Execution Time: 80.959 ms
                      
                      EXPLAIN ANALYZE
                      SELECT
                          sum(value1), sum(value2), sum(value3), sum(value4), sum(value5),
                          sum(value6), sum(value7), sum(value8), sum(value9), sum(value10),
                          sum(value11), sum(value12), sum(value13), sum(value14), sum(value15), sum(value16)
                      FROM metrics_fixed;
                      
                      Finalize Aggregate  (cost=27439.68..27439.69 rows=1 width=512) (actual time=133.084..135.022 rows=1.00 loops=1)
                        Buffers: shared hit=10310
                        ->  Gather  (cost=27439.09..27439.40 rows=3 width=512) (actual time=132.909..134.984 rows=4.00 loops=1)
                              Workers Planned: 3
                              Workers Launched: 3
                              Buffers: shared hit=10310
                              ->  Partial Aggregate  (cost=26439.09..26439.10 rows=1 width=512) (actual time=129.484..129.485 rows=1.00 loops=4)
                                    Buffers: shared hit=10310
                                    ->  Parallel Seq Scan on metrics_fixed  (cost=0.00..13535.81 rows=322581 width=48) (actual time=0.005..10.629 rows=250000.00 loops=4)
                                          Buffers: shared hit=10310
                      Planning Time: 0.091 ms
                      Execution Time: 135.067 ms

                      В-четвертых, проблема не столько в тупой выборке с суммированием, сколько в том, что NULL поле не требует поиска в индексе справочника, тогда как 0 - требует.

                      Поэтому имеем в результате
                      CREATE TABLE stations (
                        id integer PRIMARY KEY, -- идентификатор станиции
                        name varchar NOT NULL   -- наименование станции
                      );
                      
                      INSERT INTO stations (id, name)
                      SELECT G.i, G.i::text
                      FROM generate_series(1,40000) G(i)
                      UNION ALL
                      SELECT 0, '-- отсутствует --';
                      
                      DROP TABLE hauls_fixed;
                      DROP TABLE hauls_nullable;
                      CREATE TABLE hauls_nullable (
                        id serial PRIMARY KEY,
                        from_staion_id integer NOT NULL REFERENCES stations, -- станция отправления
                        to_staion_id integer NOT NULL REFERENCES stations, -- станция назначения
                        border_station_id integer NULL REFERENCES stations, -- станция пересечения границы
                        export_station_id integer NULL REFERENCES stations, -- станция экспортного досмотра
                        import_station_id integer NULL REFERENCES stations -- станция импортного досмотра
                      );
                      
                      INSERT INTO hauls_nullable (from_staion_id, to_staion_id, border_station_id, export_station_id, import_station_id)
                      SELECT random()*39999+1, random()*39999+1,
                        CASE WHEN random()<0.1 THEN random()*39999+1 END,
                        CASE WHEN random()<0.1 THEN random()*39999+1 END,
                        CASE WHEN random()<0.1 THEN random()*39999+1 END
                      FROM generate_series(1,1000000) G(i);
                      
                      CREATE TABLE hauls_fixed (
                        id serial PRIMARY KEY,
                        from_staion_id integer NOT NULL REFERENCES stations, -- станция отправления
                        to_staion_id integer NOT NULL REFERENCES stations, -- станция назначения
                        border_station_id integer NOT NULL REFERENCES stations, -- станция пересечения границы
                        export_station_id integer NOT NULL REFERENCES stations, -- станция экспортного досмотра
                        import_station_id integer NOT NULL REFERENCES stations -- станция импортного досмотра
                      );
                      
                      INSERT INTO hauls_fixed (from_staion_id, to_staion_id, border_station_id, export_station_id, import_station_id)
                      SELECT from_staion_id, to_staion_id, 
                        COALESCE(border_station_id,0), COALESCE(export_station_id,0),
                        COALESCE(import_station_id,0)
                      FROM hauls_nullable;
                      
                      EXPLAIN ANALYZE
                      SELECT COALESCE(B.name,'-- отсутствует --'),
                        COALESCE(E.name,'-- отсутствует --'),
                        COALESCE(I.name,'-- отсутствует --')
                      FROM hauls_nullable H
                      LEFT JOIN stations B ON B.id=H.border_station_id
                      LEFT JOIN stations E ON E.id=H.export_station_id
                      LEFT JOIN stations I ON I.id=H.import_station_id;
                      
                      Hash Left Join  (cost=3993.07..27297.13 rows=1000000 width=96) (actual time=19.255..387.291 rows=1000000.00 loops=1)
                        Hash Cond: (h.import_station_id = i.id)
                        Buffers: shared hit=6722
                        ->  Hash Left Join  (cost=2662.05..23341.09 rows=1000000 width=14) (actual time=12.366..271.725 rows=1000000.00 loops=1)
                              Hash Cond: (h.export_station_id = e.id)
                              Buffers: shared hit=6291
                              ->  Hash Left Join  (cost=1331.02..19385.04 rows=1000000 width=13) (actual time=6.237..173.591 rows=1000000.00 loops=1)
                                    Hash Cond: (h.border_station_id = b.id)
                                    Buffers: shared hit=5860
                                    ->  Seq Scan on hauls_nullable h  (cost=0.00..15429.00 rows=1000000 width=12) (actual time=0.010..40.307 rows=1000000.00 loops=1)
                                          Buffers: shared hit=5429
                                    ->  Hash  (cost=831.01..831.01 rows=40001 width=9) (actual time=6.181..6.182 rows=40001.00 loops=1)
                                          Buckets: 65536  Batches: 1  Memory Usage: 2227kB
                                          Buffers: shared hit=431
                                          ->  Seq Scan on stations b  (cost=0.00..831.01 rows=40001 width=9) (actual time=0.013..2.531 rows=40001.00 loops=1)
                                                Buffers: shared hit=431
                              ->  Hash  (cost=831.01..831.01 rows=40001 width=9) (actual time=6.081..6.082 rows=40001.00 loops=1)
                                    Buckets: 65536  Batches: 1  Memory Usage: 2227kB
                                    Buffers: shared hit=431
                                    ->  Seq Scan on stations e  (cost=0.00..831.01 rows=40001 width=9) (actual time=0.009..2.539 rows=40001.00 loops=1)
                                          Buffers: shared hit=431
                        ->  Hash  (cost=831.01..831.01 rows=40001 width=9) (actual time=6.839..6.840 rows=40001.00 loops=1)
                              Buckets: 65536  Batches: 1  Memory Usage: 2227kB
                              Buffers: shared hit=431
                              ->  Seq Scan on stations i  (cost=0.00..831.01 rows=40001 width=9) (actual time=0.008..2.281 rows=40001.00 loops=1)
                                    Buffers: shared hit=431
                      Planning:
                        Buffers: shared hit=18
                      Planning Time: 0.266 ms
                      Execution Time: 416.061 ms
                      
                      EXPLAIN ANALYZE
                      SELECT B.name, E.name, I.name
                      FROM hauls_fixed H
                      JOIN stations B ON B.id=H.border_station_id
                      JOIN stations E ON E.id=H.export_station_id
                      JOIN stations I ON I.id=H.import_station_id;
                      
                      Hash Join  (cost=3993.07..28238.90 rows=1000000 width=15) (actual time=18.645..459.774 rows=1000000.00 loops=1)
                        Hash Cond: (h.import_station_id = i.id)
                        Buffers: shared hit=7663
                        ->  Hash Join  (cost=2662.05..24282.60 rows=1000000 width=14) (actual time=12.302..324.342 rows=1000000.00 loops=1)
                              Hash Cond: (h.export_station_id = e.id)
                              Buffers: shared hit=7232
                              ->  Hash Join  (cost=1331.02..20326.30 rows=1000000 width=13) (actual time=6.391..196.050 rows=1000000.00 loops=1)
                                    Hash Cond: (h.border_station_id = b.id)
                                    Buffers: shared hit=6801
                                    ->  Seq Scan on hauls_fixed h  (cost=0.00..16370.00 rows=1000000 width=12) (actual time=0.009..39.691 rows=1000000.00 loops=1)
                                          Buffers: shared hit=6370
                                    ->  Hash  (cost=831.01..831.01 rows=40001 width=9) (actual time=6.336..6.337 rows=40001.00 loops=1)
                                          Buckets: 65536  Batches: 1  Memory Usage: 2227kB
                                          Buffers: shared hit=431
                                          ->  Seq Scan on stations b  (cost=0.00..831.01 rows=40001 width=9) (actual time=0.007..2.269 rows=40001.00 loops=1)
                                                Buffers: shared hit=431
                              ->  Hash  (cost=831.01..831.01 rows=40001 width=9) (actual time=5.863..5.863 rows=40001.00 loops=1)
                                    Buckets: 65536  Batches: 1  Memory Usage: 2227kB
                                    Buffers: shared hit=431
                                    ->  Seq Scan on stations e  (cost=0.00..831.01 rows=40001 width=9) (actual time=0.012..2.315 rows=40001.00 loops=1)
                                          Buffers: shared hit=431
                        ->  Hash  (cost=831.01..831.01 rows=40001 width=9) (actual time=6.298..6.299 rows=40001.00 loops=1)
                              Buckets: 65536  Batches: 1  Memory Usage: 2227kB
                              Buffers: shared hit=431
                              ->  Seq Scan on stations i  (cost=0.00..831.01 rows=40001 width=9) (actual time=0.015..2.631 rows=40001.00 loops=1)
                                    Buffers: shared hit=431
                      Planning:
                        Buffers: shared hit=18
                      Planning Time: 0.261 ms
                      Execution Time: 488.732 ms

                      Пример приближен к реальности, так как действительно 90% грузоперевозок выполняются внутри РФ. Если же посмотреть на форматы данных АСОУП-3 или ЭТРАН, то там 99% полей nullable (minOccurs=“0” в xsd) и по факту, например в ж/д накладной заполняются в среднем 5% полей. Что, в принципе, логично. Так как ссылки на договора и акты охраны указывается для 0.01% грузоотправок, ссылки на договора и акты сопровождения - 0.1%, есть множество полей, заполняемых только для некоторых видов грузов, локомотивов или видов вагонов и т.п. Привожу в качестве примера, так как xsd обмена с АСОУП-3 и ЭТРАН общедоступны.

                      Откуда пошли ERP-системы - вопрос интересный.

                      Потому что это самый простой вариант реальной и достаточно сложной структуры БД, доступ к которой есть у любого желающего. Вы можете предложить иной вариант доступной Вам и мне реальной enterprise БД?


                      1. SabMakc
                        20.05.2026 10:07

                        Если возвращаться к теме вопроса - “0 вместо NULL” (а то мне сложно вникать во взаимосвязь “10+ лет назад”, “БД на сотни гигов” и “в metrics_nullable не должно быть нулей” и комментировать “у всех под рукой ERP”).

                        • Да, от LEFT JOIN не избавиться полностью. Отчеты/аналитика все равно будут требовать его использования.

                        • Есть небольшой оверхед к размеру таблиц (к слову, меньше чем я ожидал). Но есть и некоторый профит в быстродействии.

                        • Обычные индексы могут больше места занимать (partial-индексы в помощь).

                        • В каких-то особых случаях не подойдет.

                        P.S. размер БД и отдельных таблиц в ней в петабайты и терабайты - это мерило успеха или криворукости? Лично я как-то не уверен, какой вариант правильнее. Вероятно, ответ где-то посредине )

                        P.P.S. может пример и приближен к реальности (хотя я и не уверен - но это отдельная тема, не относящаяся к основному вопросу), но, в любом случае, это явно не самая типовая ситуация.


  1. Akina
    20.05.2026 10:07

    Теперь развернём NOT IN в то, чем он работает под капотом. x IN (a, b, c) — это сокращение для x = a OR x = b OR x = c. Соответственно, x NOT IN (a, b, c) — это NOT (x = a OR x = b OR x = c), по законам логики — x <> a AND x <> b AND x <> c.

    Кстати.. вот сижу и думаю - а применимы ли тут законы бинарной логики?


    1. ptr128
      20.05.2026 10:07

      Не применимы, так как значением NULL=a и NULL<>a будет не false или true, а NULL