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.
Звідси несподіванки. WHERE company_id <> 1 не поверне вакансію без компанії: NULL <> 1 теж UNKNOWN. NOT від UNKNOWN лишається UNKNOWN, тож і NOT (company_id = 1) її не поверне. У TRUE OR UNKNOWN дає TRUE, FALSE AND UNKNOWN дає FALSE, а майже все інше з UNKNOWN дає UNKNOWN.
Різні частини SQL трактують NULL по-різному. Агрегати, крім COUNT(*), пропускають NULL. GROUP BY і DISTINCT складають усі NULL в одну групу. UNIQUE вважає NULL різними і дозволяє їх скільки завгодно. Щоб порівняти два значення так, ніби NULL дорівнює NULL, є у PostgreSQL і SQLite та <=> у MySQL, а підставляє замість NULL запасне значення.
Що повертає порівняння з NULL
Для рядка вакансії Symfony Dev, де company_id і salary_max дорівнюють NULL. Результат UNKNOWN у WHERE означає, що рядок не пройде.
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.
Де опиняться вакансії без зарплати
Сортування вакансій за зарплатою за спаданням. У PostgreSQL NULL вважається більшим за будь-яке значення, тож у DESC вакансії без зарплати опиняються першими. У MySQL і SQLite NULL менший за все, і в DESC він іде в кінець. Перенесли сайт з MySQL на PostgreSQL, і верх каталогу заповнився вакансіями без зарплати.
Стандартне рішення NULLS FIRST і NULLS LAST є в PostgreSQL і в SQLite з версії 3.30. MySQL його не знає, і там сортують спершу за ознакою IS NULL.
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), і тоді сортування читається з індексу.
+-------------+------------+ | 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.
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.
Коли NULL має дорівнювати NULL
Перевірити, чи змінилося значення при оновленні, чи знайти «всі вакансії, крім компанії 1, разом з вакансіями без компанії». Звичайне = і <> тут спотикаються об NULL. Кожна база має для цього оператор, який ніколи не повертає UNKNOWN.
title ------------- Middle PHP Symfony Dev (2 rows)
Стандартний SQL. IS NOT DISTINCT FROM дає TRUE для двох NULL. Оператора <=> у PostgreSQL немає: operator does not exist: integer <=> unknown.
+-------------+ | title | +-------------+ | Middle PHP | | Symfony Dev | +-------------+ +-------------+ | title | +-------------+ | Symfony Dev | +-------------+
<=> це «рівність з урахуванням NULL»: дає 1 або 0, але не NULL. IS DISTINCT FROM у MySQL 8.0 немає, це синтаксична помилка.
title ----------- Middle PHP Symfony Dev -- IS NOT 1 повертає ті самі два рядки
У SQLite IS і IS NOT порівнюють будь-які значення, а не тільки NULL: company_id IS 1 це рівність з урахуванням NULL. Запис IS DISTINCT FROM зʼявився в 3.39. А от <=> SQLite не знає.
Скільки 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 справжнє значення.
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.
+----------+ | COUNT(*) | +----------+ | 2 | +----------+
Обидва рядки записалися. NULLS NOT DISTINCT у MySQL немає; потрібну унікальність дає згенерована колонка з COALESCE(vacancy_id, 0) і унікальний індекс на неї.
COUNT(*) -------- 2
Так само два рядки. Частковий індекс CREATE UNIQUE INDEX ... ON feed_hidden (user_id) WHERE vacancy_id IS NULL дозволить лише один такий рядок на користувача.
NOT IN повертає нуль рядків і ще й повільніше
Компанії без вакансій, 10 000 компаній і 200 000 вакансій, кожна пʼятдесята без компанії. NOT EXISTS знаходить 1 180 компаній. NOT IN з тим самим підзапитом не знаходить жодної: серед company_id є NULL, і для кожної компанії умова id NOT IN (...) дає UNKNOWN. Бази це знають, тому NOT IN по колонці, що допускає NULL, не можуть виконати як звичайний anti-join і перевіряють кожен рядок окремо.
SELECT 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 SELECT 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 прибрано.
SELECT 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) SELECT 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, тому тут фільтр.
Приховані компанії і вакансії без компанії
У стрічці вакансій користувач може приховати компанію. Найпростіший запис whereNotIn('vacancies.company_id', $ids) мовчки прибрав би й усі вакансії без компанії: для них company_id NULL, а NULL NOT IN (...) дає UNKNOWN. Тому каталог явно додає OR company_id IS NULL. Обидві умови загорнуто в одне замикання, щоб OR не розірвав решту фільтрів запиту.
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): якщо статус не змінювали, рахує від дати відгуку.Рядки, що тихо зникли
NOT IN з підзапитом по колонці з NULL
id NOT IN (SELECT company_id FROM vacancies) повертає порожній результат, щойно в підзапиті зʼявляється хоч одна вакансія без компанії. Тести на чистих даних проходять, продакшн ламається з першим NULL.
Натомість: Пишіть NOT EXISTS. Якщо NOT IN конче потрібен, додайте в підзапит WHERE company_id IS NOT NULL.
= NULL і != NULL
WHERE deleted_at = NULL не знаходить нічого, а помилки немає. У сирому SQL і в whereRaw() це трапляється досі.
Натомість: IS NULL та IS NOT NULL, у Laravel whereNull() і whereNotNull(). where('col', null) Laravel сам перекладає в IS NULL.
<> чи NOT IN забуває про NULL
WHERE company_id <> 1 і whereNotIn('company_id', [1]) не повертають рядки, де company_id NULL. Фільтр «усі, крім» відкидає більше, ніж просили.
Натомість: Додайте OR company_id IS NULL в одній групі з умовою або пишіть IS DISTINCT FROM (<=> у MySQL).
SUM порожньої групи
SUM(salary_max) для компанії без вакансій повертає NULL, а не 0. У PHP (int) null дає 0, а в JSON API приходить null, і фронтенд показує «null грн».
Натомість: COALESCE(SUM(salary_max), 0) у запиті або явне приведення в коді. COUNT від порожньої групи завжди 0, решта агрегатів NULL.
AVG, що мовчки пропускає NULL
AVG(salary_max) по пʼяти вакансіях ділить на 4, бо вакансію без зарплати пропущено: 4750. Це правильна відповідь на питання «середня серед вказаних», але не на «середня по всіх».
Натомість: Вирішіть, що означає NULL у вашому питанні. Для «по всіх, невказана як нуль» AVG(COALESCE(salary_max, 0)) дасть 3800.
Порядок NULL після переїзду між базами
ORDER BY salary DESC у MySQL ставить NULL у кінець, у PostgreSQL на початок. Після переїзду верх списку займають записи без значення.
Натомість: Вказуйте порядок NULL явно: NULLS LAST у PostgreSQL і SQLite, ORDER BY salary IS NULL, salary DESC у MySQL.
- 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 робить те саме для двох аргументів.
Питання зі співбесід
Порівняння з 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).