<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
БАЗИ ДАНИХ · ЗАПИТИ

NULL і його пасткиNULL semantics

NULL це не нуль і не порожній рядок, а «значення невідоме». Порівняння з ним дає не TRUE і не FALSE, а третій результат, і через нього рядки тихо зникають з WHERE, NOT IN і LEFT JOIN.

16 хв читання Junior → Senior
01 · ТРИЗНАЧНА ЛОГІКА

TRUE, FALSE і невідомо

NULL у колонці salary_max означає, що зарплату не вказали, а не що вона нульова. Будь-яке порівняння з невідомим значенням теж невідоме: salary_max > 3000, salary_max = NULL і навіть NULL = NULL дають не TRUE і не FALSE, а . WHERE пропускає лише TRUE, тож рядок з UNKNOWN відкидається так само, як з FALSE. Перевірка на NULL пишеться окремим оператором: IS NULL або IS NOT NULL.

СЛОВНИКUNKNOWN. Третє логічне значення SQL. У виразах воно виглядає як NULL: SELECT NULL = 1 повертає NULL. WHERE, ON і HAVING пропускають рядок лише при TRUE, а CHECK навпаки відкидає лише FALSE, тож NULL у CHECK проходить.

Звідси несподіванки. WHERE company_id <> 1 не поверне вакансію без компанії: NULL <> 1 теж UNKNOWN. NOT від UNKNOWN лишається UNKNOWN, тож і NOT (company_id = 1) її не поверне. У TRUE OR UNKNOWN дає TRUE, FALSE AND UNKNOWN дає FALSE, а майже все інше з UNKNOWN дає UNKNOWN.

СЛОВНИКТрізначна логіка. Логіка з TRUE, FALSE і UNKNOWN. AND дає FALSE, якщо хоч один операнд FALSE; OR дає TRUE, якщо хоч один TRUE; в інших випадках з UNKNOWN результат UNKNOWN.

Різні частини SQL трактують NULL по-різному. Агрегати, крім COUNT(*), пропускають NULL. GROUP BY і DISTINCT складають усі NULL в одну групу. UNIQUE вважає NULL різними і дозволяє їх скільки завгодно. Щоб порівняти два значення так, ніби NULL дорівнює NULL, є у PostgreSQL і SQLite та <=> у MySQL, а підставляє замість NULL запасне значення.

СЛОВНИКIS DISTINCT FROM. Порівняння, яке трактує NULL як звичайне значення: NULL IS NOT DISTINCT FROM NULL дає TRUE, 1 IS DISTINCT FROM NULL теж TRUE. Ніколи не повертає UNKNOWN. У MySQL замість нього оператор <=>.
СЛОВНИКCOALESCE. Повертає перший аргумент, який не NULL: COALESCE(salary_max, 0). Є в усіх трьох базах; IFNULL у MySQL і SQLite робить те саме для двох аргументів.
02 · ВИРАЗ І РЕЗУЛЬТАТ

Що повертає порівняння з NULL

Для рядка вакансії Symfony Dev, де company_id і salary_max дорівнюють NULL. Результат UNKNOWN у WHERE означає, що рядок не пройде.

company_id = NULL, salary_max = NULL
company_id = NULL UNKNOWN Не знайде жодного рядка, навіть цей. Помилки при цьому немає, і саме тому ця помилка живе довго.
company_id IS NULL TRUE Правильна перевірка на відсутність значення.
company_id <> 1 UNKNOWN Вакансія без компанії не потрапить у «усі, крім компанії 1». Потрібно company_id <> 1 OR company_id IS NULL.
company_id IS DISTINCT FROM 1 TRUE Те саме коротше, у PostgreSQL і SQLite. У MySQL NOT (company_id <=> 1).
company_id IN (1, NULL) UNKNOWN Для 1 був би TRUE, для 2 і для NULL це UNKNOWN. NULL у списку IN нікого не знаходить.
2 NOT IN (1, NULL) UNKNOWN Це 2 <> 1 AND 2 <> NULL, тобто TRUE AND UNKNOWN. NOT IN зі списком, де є NULL, не дає TRUE ніколи.
salary_max > 3000 OR company_id IS NULL TRUE UNKNOWN OR TRUE дає TRUE. OR рятує, якщо хоч одна частина точно істинна.
COALESCE(salary_max, 0) > 3000 FALSE Невідому зарплату явно вважаємо нулем. Результат визначений, але індекс на salary_max така умова вже не використає.

Кожен вираз виконано на MySQL 8.0, PostgreSQL 16 і SQLite 3.45. MySQL і SQLite показують TRUE і FALSE як 1 і 0, PostgreSQL як t і f.

03 · NULL У СОРТУВАННІ

Де опиняться вакансії без зарплати

Сортування вакансій за зарплатою за спаданням. У PostgreSQL NULL вважається більшим за будь-яке значення, тож у DESC вакансії без зарплати опиняються першими. У MySQL і SQLite NULL менший за все, і в DESC він іде в кінець. Перенесли сайт з MySQL на PostgreSQL, і верх каталогу заповнився вакансіями без зарплати.

Стандартне рішення NULLS FIRST і NULLS LAST є в PostgreSQL і в SQLite з версії 3.30. MySQL його не знає, і там сортують спершу за ознакою IS NULL.

PostgreSQL 16 MySQL 8.0 SQLite 3.45
1SELECT title, salary_max FROM vacancies
2ORDER BY salary_max DESC;
3
4SELECT title, salary_max FROM vacancies
5ORDER BY salary_max DESC NULLS LAST;
    title    | salary_max
-------------+------------
 Symfony Dev |       NULL
 Team Lead   |       6000
 Senior PHP  |       5000
 Laravel Dev |       5000
 Middle PHP  |       3000

    title    | salary_max
-------------+------------
 Team Lead   |       6000
 Senior PHP  |       5000
 Laravel Dev |       5000
 Middle PHP  |       3000
 Symfony Dev |       NULL

За замовчуванням NULLS LAST для ASC і NULLS FIRST для DESC. Індекс можна створити з тим самим порядком: CREATE INDEX ... (salary_max DESC NULLS LAST), і тоді сортування читається з індексу.

1SELECT title, salary_max FROM vacancies
2ORDER BY salary_max DESC;
3
4-- NULLS LAST немає, сортуємо за IS NULL:
5SELECT title, salary_max FROM vacancies
6ORDER BY salary_max IS NULL, salary_max DESC;
+-------------+------------+
| title       | salary_max |
+-------------+------------+
| Team Lead   |       6000 |
| Senior PHP  |       5000 |
| Laravel Dev |       5000 |
| Middle PHP  |       3000 |
| Symfony Dev |       NULL |
+-------------+------------+
-- ORDER BY salary_max DESC NULLS LAST:
ERROR 1064 (42000): You have an error in your SQL syntax

NULL тут найменше значення: перший в ASC, останній у DESC. Щоб NULL опинився першим у DESC або останнім в ASC, сортують за salary_max IS NULL, яке дає 0 або 1.

1SELECT title, salary_max FROM vacancies
2ORDER BY salary_max DESC;
3
4SELECT title, salary_max FROM vacancies
5ORDER BY salary_max NULLS FIRST;
title        salary_max
-----------  ----------
Team Lead    6000
Senior PHP   5000
Laravel Dev  5000
Middle PHP   3000
Symfony Dev  NULL

title        salary_max
-----------  ----------
Symfony Dev  NULL
Middle PHP   3000
Senior PHP   5000
Laravel Dev  5000
Team Lead    6000

Порядок за замовчуванням як у MySQL, а синтаксис NULLS FIRST і NULLS LAST як у PostgreSQL. Тому ORDER BY ... NULLS LAST проходить і тести на SQLite, і продакшн на PostgreSQL, але не MySQL.

04 · ПОРІВНЯННЯ З УРАХУВАННЯМ NULL

Коли NULL має дорівнювати NULL

Перевірити, чи змінилося значення при оновленні, чи знайти «всі вакансії, крім компанії 1, разом з вакансіями без компанії». Звичайне = і <> тут спотикаються об NULL. Кожна база має для цього оператор, який ніколи не повертає UNKNOWN.

PostgreSQL 16 MySQL 8.0 SQLite 3.45
1SELECT title FROM vacancies
2WHERE company_id IS DISTINCT FROM 1;
    title
-------------
 Middle PHP
 Symfony Dev
(2 rows)

Стандартний SQL. IS NOT DISTINCT FROM дає TRUE для двох NULL. Оператора <=> у PostgreSQL немає: operator does not exist: integer <=> unknown.

1SELECT title FROM vacancies
2WHERE NOT (company_id <=> 1);
3
4SELECT title FROM vacancies
5WHERE company_id <=> NULL;
+-------------+
| title       |
+-------------+
| Middle PHP  |
| Symfony Dev |
+-------------+
+-------------+
| title       |
+-------------+
| Symfony Dev |
+-------------+

<=> це «рівність з урахуванням NULL»: дає 1 або 0, але не NULL. IS DISTINCT FROM у MySQL 8.0 немає, це синтаксична помилка.

1SELECT title FROM vacancies
2WHERE company_id IS DISTINCT FROM 1;
3
4SELECT title FROM vacancies
5WHERE company_id IS NOT 1;
title
-----------
Middle PHP
Symfony Dev
-- IS NOT 1 повертає ті самі два рядки

У SQLite IS і IS NOT порівнюють будь-які значення, а не тільки NULL: company_id IS 1 це рівність з урахуванням NULL. Запис IS DISTINCT FROM зʼявився в 3.39. А от <=> SQLite не знає.

05 · UNIQUE І NULL

Скільки NULL пропустить унікальний індекс

Для UNIQUE два NULL не однакові: невідоме значення не може збігтися з іншим невідомим. Тому UNIQUE (user_id, vacancy_id) пропускає скільки завгодно рядків з тим самим user_id і vacancy_id = NULL. Так поводяться всі три бази.

Коли NULL має бути лише один, у PostgreSQL 15+ є NULLS NOT DISTINCT. В інших базах і старших версіях роблять частковий унікальний індекс WHERE vacancy_id IS NULL (PostgreSQL і SQLite) або підставляють замість NULL справжнє значення.

PostgreSQL 16 MySQL 8.0 SQLite 3.45
1CREATE TABLE feed_hidden (
2 user_id int NOT NULL,
3 vacancy_id int,
4 UNIQUE NULLS NOT DISTINCT (user_id, vacancy_id)
5);
6INSERT INTO feed_hidden VALUES (7, NULL);
7INSERT INTO feed_hidden VALUES (7, NULL);
INSERT 0 1
ERROR:  duplicate key value violates unique constraint
        "feed_hidden_user_id_vacancy_id_key"
DETAIL:  Key (user_id, vacancy_id)=(7, null) already exists.
-- без NULLS NOT DISTINCT обидві вставки проходять

NULLS NOT DISTINCT зʼявився в PostgreSQL 15. Без нього поведінка та сама, що в MySQL і SQLite.

1CREATE TABLE feed_hidden (
2 user_id INT NOT NULL,
3 vacancy_id INT,
4 UNIQUE (user_id, vacancy_id)
5);
6INSERT INTO feed_hidden VALUES (7, NULL);
7INSERT INTO feed_hidden VALUES (7, NULL);
8SELECT COUNT(*) FROM feed_hidden;
+----------+
| COUNT(*) |
+----------+
|        2 |
+----------+

Обидва рядки записалися. NULLS NOT DISTINCT у MySQL немає; потрібну унікальність дає згенерована колонка з COALESCE(vacancy_id, 0) і унікальний індекс на неї.

1CREATE TABLE feed_hidden (
2 user_id INT NOT NULL,
3 vacancy_id INT,
4 UNIQUE (user_id, vacancy_id)
5);
6INSERT INTO feed_hidden VALUES (7, NULL);
7INSERT INTO feed_hidden VALUES (7, NULL);
8SELECT COUNT(*) FROM feed_hidden;
COUNT(*)
--------
2

Так само два рядки. Частковий індекс CREATE UNIQUE INDEX ... ON feed_hidden (user_id) WHERE vacancy_id IS NULL дозволить лише один такий рядок на користувача.

06 · NOT IN У ПЛАНІ

NOT IN повертає нуль рядків і ще й повільніше

Компанії без вакансій, 10 000 компаній і 200 000 вакансій, кожна пʼятдесята без компанії. NOT EXISTS знаходить 1 180 компаній. NOT IN з тим самим підзапитом не знаходить жодної: серед company_id є NULL, і для кожної компанії умова id NOT IN (...) дає UNKNOWN. Бази це знають, тому NOT IN по колонці, що допускає NULL, не можуть виконати як звичайний anti-join і перевіряють кожен рядок окремо.

NOT EXISTSSELECT c.id, c.name FROM companies c WHERE NOT EXISTS (SELECT 1 FROM vacancies v WHERE v.company_id = c.id);
Nested Loop Anti Join  (cost=0.29..3665.39 rows=1186 width=16) (actual time=0.015..10.995 rows=1180 loops=1)
  ->  Seq Scan on companies c  (actual time=0.005..0.694 rows=10000 loops=1)
  ->  Index Only Scan using vacancies_company_id_index on vacancies v  (actual time=0.001..0.001 rows=1 loops=10000)
        Index Cond: (company_id = c.id)
Execution Time: 11.051 ms
NOT INSELECT c.id, c.name FROM companies c WHERE c.id NOT IN (SELECT v.company_id FROM vacancies v);
Seq Scan on companies c  (cost=4165.00..4363.00 rows=5000 width=16) (actual time=49.817..49.818 rows=0 loops=1)
  Filter: (NOT (hashed SubPlan 1))
  Rows Removed by Filter: 10000
  SubPlan 1
    ->  Seq Scan on vacancies v  (actual time=0.015..16.437 rows=200000 loops=1)
Execution Time: 50.266 ms

PostgreSQL 16, 10 000 компаній і 200 000 вакансій з індексом на company_id. Вивід знято зі справжньої бази, частину оцінок і рядки Planning Time прибрано.

NOT EXISTSSELECT c.id, c.name FROM companies c WHERE NOT EXISTS (SELECT 1 FROM vacancies v WHERE v.company_id = c.id);
-> Nested loop antijoin  (actual time=0.116..49.8 rows=1180 loops=1)
    -> Table scan on c  (actual time=0.0959..2.82 rows=10000 loops=1)
    -> Covering index lookup on v using vacancies_company_id_index (company_id=c.id)
NOT INSELECT c.id, c.name FROM companies c WHERE c.id NOT IN (SELECT v.company_id FROM vacancies v);
-> Filter: <in_optimizer>(c.id,c.id in (select #2) is false)  (actual time=71.3..71.3 rows=0 loops=1)
    -> Table scan on c  (actual time=0.159..2.09 rows=10000 loops=1)
    -> Select #2 (subquery in condition; run only once)
        -> Materialize with deduplication  (actual time=61.2..61.2 rows=8821 loops=1)
            -> Covering index scan on v using vacancies_company_id_index  (rows=200000)

MySQL 8.0.46, InnoDB, ті самі дані. Оцінки cost прибрано.

rows=0
Фактичний результат NOT IN: нуль компаній. Запит не впав і не попередив, він просто чесно виконав трізначну логіку.
hashed SubPlan
PostgreSQL зібрав усі 200 000 company_id у хеш-таблицю і перевіряє кожну компанію окремо. Anti Join тут неможливий: семантика NOT IN з NULL інша.
Rows Removed by Filter
Усі 10 000 компаній відкинуто: для кожної id NOT IN (..., NULL) дав UNKNOWN.
is false
MySQL перевіряє, що IN дав саме FALSE. Для кожної компанії він дав NULL, бо серед значень є NULL, тож жоден рядок не пройшов: rows=0.
Materialize with deduplication
Підзапит виконано один раз і складено в тимчасову таблицю з 8 821 різного значення, разом з NULL.
antijoin
NOT IN MySQL перетворює на anti-join, лише коли обидві колонки оголошені NOT NULL. company_id допускає NULL, тому тут фільтр.
07 · ЯК У PHP UKRAINE

Приховані компанії і вакансії без компанії

У стрічці вакансій користувач може приховати компанію. Найпростіший запис whereNotIn('vacancies.company_id', $ids) мовчки прибрав би й усі вакансії без компанії: для них company_id NULL, а NULL NOT IN (...) дає UNKNOWN. Тому каталог явно додає OR company_id IS NULL. Обидві умови загорнуто в одне замикання, щоб OR не розірвав решту фільтрів запиту.

php EloquentJobCatalog.php фрагмент
1// Qualified: the facet queries join json_each, which has an id column of its own.
2if ($filter->excludeJobIds !== []) {
3 $query->whereNotIn('vacancies.id', $filter->excludeJobIds);← id не буває NULL, тут NOT IN безпечний
4}
5
6if ($filter->excludeCompanyIds !== []) {
7 $query->where(fn (Builder $q) => $q->whereNull('vacancies.company_id')->orWhereNotIn('vacancies.company_id', $filter->excludeCompanyIds));← вакансії без компанії лишаються
8}
src/Hiring/Infrastructure/Catalog/EloquentCompanyDirectory.phpТой самий прийом для компаній, які не є рекрутерами: whereNull('kind')->orWhere('kind', '!=', CompanyKind::Recruiter->value). Без whereNull компанії з невідомим kind зникли б зі списку.
database/migrations/2026_09_10_000003_create_job_search.phpfeed_hidden зберігає або vacancy_id, або company_id, інша колонка NULL. Індекси unique(['user_id', 'vacancy_id']) і unique(['user_id', 'company_id']) пропускають скільки завгодно NULL, тож користувач може приховати багато компаній, і кожну лише раз.
src/Hiring/Infrastructure/Catalog/EloquentJobCatalog.phpСортування за зарплатою: orderByRaw('salary_usd_to DESC NULLS LAST'). Без NULLS LAST PostgreSQL поставив би вакансії без зарплати першими. Запис працює на PostgreSQL і на SQLite в тестах, на MySQL він би впав. Індекс на salary_usd_to у міграції звичайний, тож такий порядок PostgreSQL з нього не прочитає і сортує окремим кроком.
app/Filament/Resources/Applications/ApplicationResource.phpФільтр «відгуки без руху тиждень» бере coalesce(status_changed_at, created_at): якщо статус не змінювали, рахує від дати відгуку.
08 · ДЕ NULL КУСАЄ

Рядки, що тихо зникли

✕ 1

NOT IN з підзапитом по колонці з NULL

id NOT IN (SELECT company_id FROM vacancies) повертає порожній результат, щойно в підзапиті зʼявляється хоч одна вакансія без компанії. Тести на чистих даних проходять, продакшн ламається з першим NULL.

Натомість: Пишіть NOT EXISTS. Якщо NOT IN конче потрібен, додайте в підзапит WHERE company_id IS NOT NULL.

✕ 2

= NULL і != NULL

WHERE deleted_at = NULL не знаходить нічого, а помилки немає. У сирому SQL і в whereRaw() це трапляється досі.

Натомість: IS NULL та IS NOT NULL, у Laravel whereNull() і whereNotNull(). where('col', null) Laravel сам перекладає в IS NULL.

✕ 3

<> чи NOT IN забуває про NULL

WHERE company_id <> 1 і whereNotIn('company_id', [1]) не повертають рядки, де company_id NULL. Фільтр «усі, крім» відкидає більше, ніж просили.

Натомість: Додайте OR company_id IS NULL в одній групі з умовою або пишіть IS DISTINCT FROM (<=> у MySQL).

✕ 4

SUM порожньої групи

SUM(salary_max) для компанії без вакансій повертає NULL, а не 0. У PHP (int) null дає 0, а в JSON API приходить null, і фронтенд показує «null грн».

Натомість: COALESCE(SUM(salary_max), 0) у запиті або явне приведення в коді. COUNT від порожньої групи завжди 0, решта агрегатів NULL.

✕ 5

AVG, що мовчки пропускає NULL

AVG(salary_max) по пʼяти вакансіях ділить на 4, бо вакансію без зарплати пропущено: 4750. Це правильна відповідь на питання «середня серед вказаних», але не на «середня по всіх».

Натомість: Вирішіть, що означає NULL у вашому питанні. Для «по всіх, невказана як нуль» AVG(COALESCE(salary_max, 0)) дасть 3800.

✕ 6

Порядок NULL після переїзду між базами

ORDER BY salary DESC у MySQL ставить NULL у кінець, у PostgreSQL на початок. Після переїзду верх списку займають записи без значення.

Натомість: Вказуйте порядок NULL явно: NULLS LAST у PostgreSQL і SQLite, ORDER BY salary IS NULL, salary DESC у MySQL.

09 · СЛОВНИК
UNKNOWN
Третє логічне значення SQL. У виразах воно виглядає як NULL: SELECT NULL = 1 повертає NULL. WHERE, ON і HAVING пропускають рядок лише при TRUE, а CHECK навпаки відкидає лише FALSE, тож NULL у CHECK проходить.
Трізначна логіка
Логіка з TRUE, FALSE і UNKNOWN. AND дає FALSE, якщо хоч один операнд FALSE; OR дає TRUE, якщо хоч один TRUE; в інших випадках з UNKNOWN результат UNKNOWN.
IS DISTINCT FROM
Порівняння, яке трактує NULL як звичайне значення: NULL IS NOT DISTINCT FROM NULL дає TRUE, 1 IS DISTINCT FROM NULL теж TRUE. Ніколи не повертає UNKNOWN. У MySQL замість нього оператор <=>.
COALESCE
Повертає перший аргумент, який не NULL: COALESCE(salary_max, 0). Є в усіх трьох базах; IFNULL у MySQL і SQLite робить те саме для двох аргументів.
10 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з SQL →

Порівняння з NULL дає UNKNOWN, а WHERE пропускає лише TRUE. NULL означає невідоме значення, і база не може стверджувати, що одне невідоме дорівнює іншому. Для перевірки є IS NULL.

Серед company_id є NULL. id NOT IN (1, 2, NULL) розкладається на id <> 1 AND id <> 2 AND id <> NULL, і остання частина дає UNKNOWN. TRUE AND UNKNOWN дає UNKNOWN, тож умова не стає TRUE для жодної компанії. Рішення: NOT EXISTS або WHERE company_id IS NOT NULL у підзапиті.

У MySQL, PostgreSQL і SQLite так: для унікальності NULL не дорівнюють один одному. PostgreSQL 15+ дозволяє змінити це через UNIQUE NULLS NOT DISTINCT. Інший спосіб обмежити кількість NULL це частковий унікальний індекс з WHERE колонка IS NULL у PostgreSQL і SQLite.

PostgreSQL вважає NULL більшим за будь-яке значення, тож у DESC вакансії без зарплати пішли наверх; MySQL ставив їх у кінець. Звичайний індекс на salary лежить як ASC NULLS LAST, і прочитаний задом наперед дає DESC NULLS FIRST. Порядок DESC NULLS LAST з нього не отримати, і замість Index Scan Backward у плані зʼявляється Sort по всій таблиці. Рішення: індекс з тим самим порядком, (salary DESC NULLS LAST).