Пари рядків, а не перетин кіл
JOIN бере кожен рядок лівої таблиці й шукає йому пари в правій за умовою ON. Кожна знайдена пара стає окремим рядком результату. Kvitka Soft з трьома вакансіями дає три рядки, Lviv Pay без вакансій у не дає жодного. Кола Венна показують, які ключі збіглися, і мовчать про головне: результат буває довшим за обидві таблиці разом.
лишає кожен рядок лівої таблиці. Якщо пари немає, праві колонки приходять як NULL. RIGHT JOIN робить те саме дзеркально, і його зазвичай переписують у LEFT, помінявши таблиці місцями: так запит читається зліва направо. лишає рядки без пари з обох боків.
Умова ON порівнює значення, а NULL не дорівнює нічому, навіть іншому NULL. Вакансія з company_id = NULL не знайде компанії ні в INNER, ні в LEFT від companies. обходиться без умови і дає всі комбінації: 3 компанії на 5 вакансій це 15 рядків. зʼєднує таблицю з нею самою, а LEFT JOIN з перевіркою на NULL чи NOT EXISTS дають : рядки, яким пари не знайшлося.
Умова на праву таблицю
Класична пастка. Треба показати всі компанії з їхніми активними вакансіями, і компанії без активних теж. Пишемо LEFT JOIN, а умову на статус кладемо в WHERE. У компанії без пари v.status це NULL, NULL = 'active' дає не TRUE, а NULL, і WHERE відкидає рядок. LEFT JOIN тихо перетворився на INNER, і PostgreSQL навіть сам перебудовує його на звичайний JOIN, бо бачить, що рядки з NULL однаково не пройдуть.
Правило: умови, які вирішують, що вважати парою, ідуть в ON. У WHERE лишаються умови на ліву таблицю і перевірка IS NULL, коли рядки без пари потрібні навмисно.
... ON v.company_id = c.id AND v.status = 'active'
LEFT лишився
Усі компанії на місці. Lviv Pay і компанія лише із закритими вакансіями приходять з NULL у v.title.
WHERE v.status = 'active'
став INNER
Lviv Pay зникла: її NULL у v.status не пройшов перевірку. Результат той самий, що в INNER JOIN.
WHERE v.status = 'active' OR v.id IS NULL
інший сенс
Lviv Pay повернулася, а компанія, у якої лише закриті вакансії, зникла: пару вона знайшла, але пара не active. Це не те саме, що умова в ON.
WHERE v.id IS NULL
anti-join
Лишаються компанії без жодної вакансії. Тут NULL у правій колонці і є метою.
WHERE c.city = 'Київ'
безпечно
Умова на ліву таблицю в WHERE нічого не ламає: вона відбирає компанії, а не пари.
Кожен рядок перевірено на MySQL 8.0, PostgreSQL 16 і SQLite 3.45 на даних стенда з головної розділу і ще одній компанії, у якої є лише закрита вакансія.
FULL JOIN, якого в MySQL немає
FULL JOIN потрібен рідко: звірити дві таблиці й побачити, чого бракує з кожного боку. PostgreSQL і SQLite з версії 3.39 вміють його прямо. MySQL такого синтаксису не має, і FULL JOIN збирають з двох половин: LEFT JOIN дає все зліва, RIGHT JOIN з умовою c.id IS NULL додає лише праві рядки без пари.
Половини склеюють через UNION ALL, а не через UNION. UNION прибирає однакові рядки, а дві різні вакансії з однаковою назвою в одній компанії це два законні рядки результату.
name | title -------------+------------- Kvitka Soft | Senior PHP Kvitka Soft | Laravel Dev Kvitka Soft | Team Lead Dnipro Labs | Middle PHP Lviv Pay | NULL NULL | Symfony Dev (6 rows)
NULL у name означає вакансію без компанії, NULL у title компанію без вакансій. У PostgreSQL NULL при сортуванні за зростанням іде в кінці, тому вакансія без компанії остання.
+-------------+-------------+ | name | title | +-------------+-------------+ | Kvitka Soft | Team Lead | | Kvitka Soft | Laravel Dev | | Kvitka Soft | Senior PHP | | Dnipro Labs | Middle PHP | | Lviv Pay | NULL | | NULL | Symfony Dev | +-------------+-------------+ -- а просто FULL JOIN: ERROR 1064 (42000): You have an error in your SQL syntax
Ті самі шість рядків, хоч порядок інший: без ORDER BY база віддає рядки так, як їй зручно. І пастка: якщо в companies FULL JOIN vacancies у таблиці немає аліаса, MySQL прочитає FULL як аліас companies і мовчки виконає звичайний INNER JOIN.
name title ----------- ----------- NULL Symfony Dev Kvitka Soft Senior PHP Kvitka Soft Laravel Dev Kvitka Soft Team Lead Dnipro Labs Middle PHP Lviv Pay NULL
RIGHT і FULL JOIN зʼявилися в SQLite 3.39. А NULL при сортуванні за зростанням SQLite ставить першим, як і MySQL, тому вакансія без компанії тут угорі.
Оновити рядки за умовою з іншої таблиці
Закрити всі активні вакансії компанії Kvitka Soft, а потім видалити відгуки на закриті вакансії. Умова лежить в іншій таблиці, і кожна база пише це по-своєму: MySQL дозволяє JOIN прямо в UPDATE і DELETE, PostgreSQL бере другу таблицю через FROM в UPDATE і через USING у DELETE. SQLite знає UPDATE ... FROM з версії 3.33, а DELETE з JOIN не вміє зовсім.
Query OK, 2 rows affected Query OK, 4 rows affected
У DELETE з JOIN після слова DELETE називають аліаси таблиць, з яких видаляти: тут лише a, вакансії лишаються.
UPDATE 2 DELETE 4
Умова зʼєднання тут у WHERE. Якщо її забути, кожен рядок vacancies отримає пару з кожною компанією, тобто CROSS JOIN.
changes() = 2 changes() = 4 -- DELETE ... USING: Parse error: near "USING": syntax error
UPDATE ... FROM у SQLite такий самий, як у PostgreSQL. Для DELETE лишається підзапит у WHERE, і він працює в усіх трьох базах.
Компанії без вакансій, три записи
Компанії без жодної вакансії знаходять через NOT EXISTS або через LEFT JOIN з IS NULL. Результат однаковий, 1 180 компаній з 10 000. План залежить від того, яку колонку ви перевіряєте на NULL. NOT EXISTS обидві бази перетворюють на anti-join завжди. LEFT JOIN з IS NULL PostgreSQL перетворює, коли перевіряють колонку з умови ON, а MySQL, коли перевірена колонка оголошена NOT NULL. Тому в однієї бази швидкий один запис, а в іншої інший.
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 LEFT JOIN vacancies v ON v.company_id = c.id WHERE v.company_id IS NULL;Nested Loop Anti Join (cost=0.29..3665.39 rows=1186 width=16) (actual time=0.020..18.916 rows=1180 loops=1)
-> Seq Scan on companies c (actual time=0.006..1.123 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: 19.060 ms SELECT c.id, c.name FROM companies c LEFT JOIN vacancies v ON v.company_id = c.id WHERE v.id IS NULL;Hash Right Join (cost=298.00..4488.22 rows=1 width=16) (actual time=50.079..50.343 rows=1180 loops=1)
Hash Cond: (v.company_id = c.id)
Filter: (v.id IS NULL)
Rows Removed by Filter: 196000
-> Seq Scan on vacancies v (actual time=0.005..13.181 rows=200000 loops=1)
-> Hash (actual time=2.327..2.330 rows=10000 loops=1)
-> Seq Scan on companies c (actual time=0.006..0.849 rows=10000 loops=1)
Execution Time: 53.958 ms PostgreSQL 16, 10 000 компаній і 200 000 вакансій з індексом на vacancies.company_id. Вивід знято зі справжньої бази, частину оцінок cost і рядки 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)
(actual time=0.00461..0.00461 rows=0.882 loops=10000) SELECT c.id, c.name FROM companies c LEFT JOIN vacancies v ON v.company_id = c.id WHERE v.id IS NULL;-> Filter: (v.id is null) (actual time=0.172..52.7 rows=1180 loops=1)
-> Nested loop antijoin (actual time=0.17..52.5 rows=1180 loops=1)
-> Table scan on c (actual time=0.119..3.05 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 LEFT JOIN vacancies v ON v.company_id = c.id WHERE v.company_id IS NULL;-> Filter: (v.company_id is null) (actual time=0.226..143 rows=1180 loops=1)
-> Nested loop left join (actual time=0.225..132 rows=197180 loops=1)
-> Table scan on c (actual time=0.183..6.08 rows=10000 loops=1)
-> Covering index lookup on v using vacancies_company_id_index (company_id=c.id)
(actual time=0.00774..0.0106 rows=19.6 loops=10000) MySQL 8.0.46, InnoDB, ті самі 10 000 компаній і 200 000 вакансій. EXPLAIN ANALYZE показує дерево плану з фактичним часом; оцінки cost прибрано.
- Anti Join
- Для кожної компанії база шукає першу вакансію й зупиняється, щойно знайшла. Компанія виходить у результат, лише якщо не знайшлося жодної.
- Hash Right Join
- Звичайне зовнішнє зʼєднання: база збирає всі 197 180 рядків зʼєднання і лише потім фільтром викидає ті, де v.id не NULL. PostgreSQL 16 не перетворює цей запис на anti-join: v.id не входить в умову ON, а обмеження NOT NULL на первинному ключі він тут не враховує.
- rows=1
- Оцінка в першому рядку третього плану: планувальник чекав один рядок, а вийшло 1 180. Така розбіжність означає, що він не зрозумів запит, а не що статистика застаріла.
- Rows Removed by Filter
- 196 000 зібраних і викинутих пар. Саме ця робота і є різницею в часі між першим і третім планом.
- antijoin
- MySQL з 8.0.17 перетворює NOT EXISTS і NOT IN на anti-join. LEFT JOIN з IS NULL він перетворює, коли перевірена колонка оголошена NOT NULL: тоді NULL у ній може означати лише відсутність пари.
- Nested loop left join
- company_id може бути NULL і сам по собі, тож MySQL не ризикує: зʼєднує все, 197 180 рядків, і фільтрує потім. Утричі довше за NOT EXISTS.
- rows=0.882
- У дереві EXPLAIN ANALYZE rows біля внутрішнього кроку це середнє на один прохід. 0.882 означає, що для частини компаній вакансій не знайшлося.
Що Eloquent пише замість вас
join() і leftJoin() дають справжній JOIN, і рядки множаться так само, як у SQL. whereHas() і withCount() JOIN не пишуть зовсім: вони будують підзапит, тож компанія з трьома вакансіями лишається одним рядком. Нижче SQL, який Laravel 13 збирає для PostgreSQL для моделі Company зі звʼязком jobs на таблицю vacancies; лапки біля назв прибрано. Якщо в моделі вакансії є глобальний scope, його умова теж опиниться всередині підзапиту.
LEFT JOIN у пошуку сайту
Пошук вакансій шукає слова і в назві вакансії, і в назві компанії, тож компанію приєднано через leftJoin(). LEFT тут обовʼязковий: company_id у vacancies може бути NULL, і вакансія без компанії через звичайний JOIN зникла б з пошуку. select(vacancies.*) потрібен, бо в обох таблицях є id, і без нього id компанії перезаписав би id вакансії в моделі. Умова на companies.name стоїть у WHERE, але не ламає LEFT JOIN: terms() кладе її в одну OR-групу з колонками вакансії, тож вакансія без компанії проходить за назвою.
database/migrations/2026_09_02_000002_create_vacancies_table.phpcompany_id оголошено як nullable() з nullOnDelete(): видалена компанія лишає свої вакансії з NULL. Тому всюди, де вакансії зʼєднують з компаніями, потрібен LEFT JOIN.src/Hiring/Infrastructure/Catalog/EloquentCompanyDirectory.phpКаталог компаній не пише JOIN: whereHas('jobs') відбирає компанії з відкритими вакансіями через EXISTS, а withCount(['jobs as open_jobs' => ...]) рахує їх підзапитом. Компанія приходить одним рядком.src/Hiring/Infrastructure/Catalog/EloquentJobCatalog.phpЛічильник міст робить CROSS JOIN з функцією, що розгортає JSON-масив: jsonb_array_elements_text у PostgreSQL і json_each у SQLite. Вакансія з трьома містами свідомо дає три рядки, і кожне місто отримує свій плюс один.Зайві рядки і зниклі рядки
Умова на праву таблицю в WHERE
LEFT JOIN vacancies і WHERE v.status = 'active' прибирають компанії без активних вакансій. Помилку не видно, поки в даних немає такої компанії.
Натомість: Переносьте умову в ON, а в Eloquent в замикання leftJoin(). У WHERE для правої таблиці лишайте тільки IS NULL.
Два JOIN один-до-багатьох в одному запиті
companies LEFT JOIN vacancies LEFT JOIN applications множить вакансії на відгуки. Kvitka Soft має 3 вакансії, але COUNT(v.id) дає 5, бо на Senior PHP три відгуки. SUM(v.salary_max) так само рахує зарплату Senior PHP тричі: 26 000 замість 16 000.
Натомість: Рахуйте кожен звʼязок окремо: COUNT(DISTINCT v.id), підзапит або withCount() на кожен звʼязок. SUM через DISTINCT не лікується, бо дві різні вакансії можуть мати однакову зарплату.
SELECT * з JOIN
В обох таблицях є id, name, created_at. Драйвер повертає рядок з однаковими ключами, і в масиві чи моделі одна колонка мовчки перезаписує іншу.
Натомість: Перелічуйте колонки з аліасами (c.name AS company_name), а в Eloquent пишіть select('vacancies.*') перед join().
Забута умова зʼєднання
FROM companies, vacancies без WHERE чи JOIN без ON (MySQL це дозволяє) це CROSS JOIN. На тестових трьох рядках непомітно, на продакшні мільйони рядків.
Натомість: Пишіть JOIN ... ON замість переліку таблиць через кому. Явний CROSS JOIN лишайте для випадків, коли декартів добуток і потрібен.
JOIN заради перевірки існування
JOIN vacancies лише для того, щоб відібрати компанії з вакансіями, дає дублі компаній. Їх прибирають DISTINCT, і база сортує чи хешує весь результат.
Натомість: Для «має хоча б одну» беріть EXISTS (whereHas() в Eloquent): він зупиняється на першій знайденій парі й не множить рядки.
- INNER JOIN
- Лишає тільки пари, для яких умова ON дала TRUE. Слово INNER необовʼязкове: просто JOIN означає те саме.
- LEFT JOIN
- Він же LEFT OUTER JOIN. Усі рядки лівої таблиці плюс знайдені пари; рядок без пари доповнюється NULL у колонках правої таблиці.
- FULL JOIN
- FULL OUTER JOIN: усі рядки обох таблиць. Пари склеюються, решта доповнюється NULL з протилежного боку. Є в PostgreSQL і SQLite з 3.39, у MySQL немає.
- CROSS JOIN
- Декартів добуток: кожен рядок зліва з кожним рядком справа. Корисний для сітки «кожна компанія на кожен місяць», небезпечний, коли зʼявляється випадково через забуту умову.
- Self join
- Таблиця, зʼєднана сама з собою під двома аліасами. Типовий випадок: батьківська категорія через parent_id або керівник у таблиці співробітників.
- Anti-join
- Рядки лівої таблиці, для яких у правій пари немає. Пишуть через NOT EXISTS або LEFT JOIN ... WHERE права_колонка IS NULL; у плані це вузол Anti Join чи antijoin.
Питання зі співбесід
Від 0 до 15. Кожна пара, для якої ON дав TRUE, це окремий рядок. Якщо всі вакансії однієї компанії, буде 5 рядків; якщо ON завжди істинний, вийде CROSS JOIN на 15. Тому кількість рядків після JOIN не можна вгадати з розміру таблиць, лише зі звʼязку між ними.
WHERE. Найчастіше там умова на колонку правої таблиці, наприклад v.status = 'active'. Для компанії без пари ця колонка NULL, умова дає NULL замість TRUE, і рядок зникає. Умову треба перенести в ON.
NOT EXISTS і LEFT JOIN ... IS NULL дають однаковий результат, і бази зазвичай виконують їх як anti-join, хоча LEFT JOIN спрацьовує не з будь-якою колонкою в IS NULL. NOT IN небезпечний: якщо в підзапиті є хоч один NULL (вакансія без компанії), умова для кожної компанії стає NULL, і запит повертає порожній результат.
Це fan-out: рядок вакансії повторюється стільки разів, скільки в неї відгуків, і SUM рахує його кожного разу. DISTINCT тут не годиться, бо зітре й різні вакансії з однаковою зарплатою. Агрегуйте кожну гілку окремо: суму зарплат з vacancies, кількість відгуків у підзапиті чи похідній таблиці, згрупованій за vacancy_id, а потім зʼєднуйте вже агреговані результати, по одному рядку на компанію з кожного боку.