<? phpukraine СПІВБЕСІДИ
Пошук по платформі
SQL · JUNIOR ЧАСТО ПИТАЮТЬ

Як NULL поводиться в порівняннях, агрегатах і унікальних індексах?

NULL — це «невідомо», а не значення: будь-яке порівняння з ним дає UNKNOWN і рядок не проходить WHERE, агрегати мовчки пропускають NULL замість того щоб рахувати їх нулями, а UNIQUE вважає різні NULL різними, тому дублікати проходять.

Чому `WHERE bonus = NULL` не повертає жодного рядка?
Чому після додавання умови `status <> 'paid'` зі звіту зникли рядки, у яких статус узагалі не заповнений?
Скільки рядків з однаковим NULL можна вставити в колонку з UNIQUE індексом?
Чому AVG по колонці дає більше число, ніж очікує бухгалтерія?
тризначна логіка UNIQUE агрегати

NULL у SQL означає не «порожньо» і не «нуль», а «значення невідоме». Через це логіка стає тризначною: вираз може бути TRUE, FALSE або UNKNOWN. bonus = NULL — це питання «чи дорівнює невідоме число тисячі», відповідь на яке невідома, тому результат UNKNOWN. WHERE пропускає далі лише рядки з TRUE, тож і = NULL, і <> NULL завжди дають порожній результат — без помилки й без попередження. Заперечення теж не допомагає: NOT UNKNOWN — це знову UNKNOWN. Саме звідси найпоширеніший баг у звітах: після додавання умови status <> 'paid' із вибірки тихо зникають усі рядки, де status взагалі не заповнений. Перевіряти наявність значення можна лише через IS NULL та IS NOT NULL, а порівнювати дві потенційно порожні колонки — через IS NOT DISTINCT FROM у PostgreSQL або <=> у MySQL.

Той самий механізм ламає NOT IN з підзапитом. Якщо підзапит повернув хоч один NULL, вираз id NOT IN (1, NULL) розкладається в id <> 1 AND id <> NULL, друга частина назавжди UNKNOWN, і результат ніколи не стане TRUE. Запит повертає нуль рядків, хоча дані на місці. Безпечна заміна — NOT EXISTS, який працює через звичайне зіставлення рядків і на NULL не спотикається; альтернатива — прибрати NULL прямо в підзапиті через WHERE col IS NOT NULL. Це одна з тих помилок, які проходять код-рев'ю і виявляються вже на проді, бо запит синтаксично коректний.

В агрегатах правило інше: SUM, AVG, MIN, MAX і COUNT(col) просто пропускають NULL, а не підставляють нуль. Тому COUNT(*) рахує рядки, COUNT(bonus) — лише заповнені бонуси, а AVG(bonus) ділить суму на кількість непорожніх значень. Для трьох співробітників із бонусами 1000, NULL і 0 середнє буде 500, а не 333.33 — і саме тут звіт розходиться з очікуваннями бізнесу. Якщо порожнє значення за змістом дорівнює нулю, це треба сказати явно: AVG(COALESCE(bonus, 0)). Окремо варто памʼятати, що SUM по порожній вибірці повертає NULL, а не 0, тому в коді така сума теж потребує COALESCE.

А от GROUP BY, DISTINCT, UNION і віконні PARTITION BY користуються третім правилом: там усі NULL вважаються однаковими й потрапляють в одну групу. Виходить, що в одному запиті NULL може бути одночасно «не рівним самому собі» у WHERE і «рівним самому собі» у GROUP BY. Унікальний індекс належить до першої категорії: NULL для нього різні, тому UNIQUE (email) спокійно пропускає скільки завгодно рядків із порожнім email — і в MySQL, і в PostgreSQL (для контрасту, SQL Server дозволяє лише один NULL). З PostgreSQL 15 зʼявився явний перемикач UNIQUE NULLS NOT DISTINCT; у MySQL 8.4 такого немає, там або NOT NULL, або перевірка на рівні застосунку, або згенерована колонка з підстановкою.

Практичний висновок: NOT NULL має бути станом за замовчуванням, а nullable-колонка — свідомим рішенням для випадків, де «невідомо» справді окремий стан (deleted_at, closed_at). Для сум і лічильників краще NOT NULL DEFAULT 0, бо будь-яка арифметика з NULL дає NULL і помилка розповзається далі по обчисленнях. Компроміс у зворотний бік теж є: заміна NULL на «магічні» значення на кшталт 0, порожнього рядка чи '1970-01-01' робить дані брехливими та псує агрегати, тож замінювати NULL заглушками лише щоб уникнути IS NULL — гірше, ніж навчитися з ним працювати.

-- Дані: employees
-- id | name  | manager_id | bonus | email
--  1 | Оля   |       NULL |  1000 | [email protected]
--  2 | Іван  |          1 |  NULL | NULL
--  3 | Петро |       NULL |     0 | NULL

-- 1. Порівняння з NULL дає UNKNOWN, а WHERE пропускає лише TRUE
SELECT * FROM employees WHERE bonus = NULL;   -- 0 рядків завжди
SELECT * FROM employees WHERE bonus IS NULL;  -- Іван

-- 2. Заперечення не рятує: NOT(UNKNOWN) теж UNKNOWN
SELECT count(*) FROM employees WHERE bonus <> 1000;  -- 1 (Петро), Іван мовчки зник

-- 3. NOT IN по nullable-колонці не поверне нічого
SELECT * FROM employees WHERE id NOT IN (SELECT manager_id FROM employees);  -- 0 рядків
-- Правильно: NOT EXISTS коректно працює з NULL
SELECT e.* FROM employees e
WHERE NOT EXISTS (SELECT 1 FROM employees m WHERE m.manager_id = e.id);

-- 4. Агрегати пропускають NULL, а не рахують його за 0
SELECT count(*)     AS rows_total,  -- 3
       count(bonus) AS with_bonus,  -- 2
       sum(bonus)   AS total,       -- 1000
       avg(bonus)   AS avg_bonus    -- 500, а не 333.33
FROM employees;

-- 5. GROUP BY і DISTINCT, навпаки, збирають усі NULL в одну групу
SELECT bonus, count(*) FROM employees GROUP BY bonus;  -- є рядок bonus = NULL

-- 6. UNIQUE вважає NULL різними: обидва рядки з email = NULL пройдуть
CREATE UNIQUE INDEX employees_email_uniq ON employees (email);
-- PostgreSQL 15+: заборонити повтори NULL явно
CREATE UNIQUE INDEX employees_email_strict ON employees (email) NULLS NOT DISTINCT;
Що SQL має тризначну логіку: TRUE, FALSE, UNKNOWN, і WHERE пропускає лише TRUE, тому і `= NULL`, і `<> NULL` дають порожній результат.
Що перевіряти треба через `IS NULL` / `IS NOT NULL`, а для порівняння двох значень, з яких обидва можуть бути NULL, є `IS NOT DISTINCT FROM` у PostgreSQL і оператор `<=>` у MySQL.
Що `COUNT(*)` рахує рядки, а `COUNT(col)`, `SUM`, `AVG` пропускають NULL: AVG ділить на кількість непорожніх значень, а не на кількість рядків.
Що GROUP BY і DISTINCT, навпаки, вважають усі NULL однією групою — це інше правило, ніж у порівняннях.
Що UNIQUE індекс у MySQL і PostgreSQL дозволяє скільки завгодно NULL, і що в PostgreSQL 15+ це вимикається через `UNIQUE NULLS NOT DISTINCT`.
Що `NOT IN` з підзапитом, який може повернути NULL, не поверне нічого, і замінюється на `NOT EXISTS`.
Писати `WHERE deleted_at = NULL` або `WHERE deleted_at != NULL` замість `IS NULL` / `IS NOT NULL`.
Вважати, що умова `status <> 'paid'` включає рядки з NULL у status: вони мовчки випадають зі звіту.
Думати, що `SUM(bonus)` рахує NULL як 0, а `AVG(bonus)` ділить на загальну кількість рядків.
Розраховувати, що UNIQUE індекс на nullable-колонці не пустить два порожні значення — пустить, і в MySQL, і в PostgreSQL.
Використовувати `NOT IN (SELECT ...)` по nullable-колонці й дивуватись порожньому результату замість `NOT EXISTS`.
Плутати NULL з порожнім рядком `''` або з 0: у SQL це три різні значення (в Oracle порожній рядок і є NULL, у MySQL і PostgreSQL — ні).
ПОРАДА

Скажіть одним реченням: «NULL — це не значення, а відсутність знання, тому порівняння з ним дає не FALSE, а UNKNOWN». І одразу назвіть три різні правила: у WHERE усі NULL різні, у GROUP BY і DISTINCT — однакові, в агрегатах — просто пропускаються.

оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

NULL — «невідомо»: `= NULL` дає UNKNOWN і рядок не проходить WHERE, перевіряють через `IS NULL`; SUM і AVG пропускають NULL, а UNIQUE у MySQL і PostgreSQL вважає NULL різними й пускає дублікати.