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