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

JOINJoins

JOIN склеює рядки двох таблиць за умовою. Один рядок зліва може дати кілька рядків результату або не дати жодного, і саме тут ховаються баги з подвоєними сумами та зниклими компаніями.

18 хв читання Junior → Senior
01 · ЯК JOIN ЗБИРАЄ РЯДКИ

Пари рядків, а не перетин кіл

JOIN бере кожен рядок лівої таблиці й шукає йому пари в правій за умовою ON. Кожна знайдена пара стає окремим рядком результату. Kvitka Soft з трьома вакансіями дає три рядки, Lviv Pay без вакансій у не дає жодного. Кола Венна показують, які ключі збіглися, і мовчать про головне: результат буває довшим за обидві таблиці разом.

СЛОВНИКINNER JOIN. Лишає тільки пари, для яких умова ON дала TRUE. Слово INNER необовʼязкове: просто JOIN означає те саме.

лишає кожен рядок лівої таблиці. Якщо пари немає, праві колонки приходять як NULL. RIGHT JOIN робить те саме дзеркально, і його зазвичай переписують у LEFT, помінявши таблиці місцями: так запит читається зліва направо. лишає рядки без пари з обох боків.

СЛОВНИКLEFT JOIN. Він же LEFT OUTER JOIN. Усі рядки лівої таблиці плюс знайдені пари; рядок без пари доповнюється NULL у колонках правої таблиці.
СЛОВНИКFULL JOIN. FULL OUTER JOIN: усі рядки обох таблиць. Пари склеюються, решта доповнюється NULL з протилежного боку. Є в PostgreSQL і SQLite з 3.39, у MySQL немає.

Умова ON порівнює значення, а NULL не дорівнює нічому, навіть іншому NULL. Вакансія з company_id = NULL не знайде компанії ні в INNER, ні в LEFT від companies. обходиться без умови і дає всі комбінації: 3 компанії на 5 вакансій це 15 рядків. зʼєднує таблицю з нею самою, а LEFT JOIN з перевіркою на NULL чи NOT EXISTS дають : рядки, яким пари не знайшлося.

СЛОВНИКCROSS JOIN. Декартів добуток: кожен рядок зліва з кожним рядком справа. Корисний для сітки «кожна компанія на кожен місяць», небезпечний, коли зʼявляється випадково через забуту умову.
СЛОВНИКSelf join. Таблиця, зʼєднана сама з собою під двома аліасами. Типовий випадок: батьківська категорія через parent_id або керівник у таблиці співробітників.
СЛОВНИКAnti-join. Рядки лівої таблиці, для яких у правій пари немає. Пишуть через NOT EXISTS або LEFT JOIN ... WHERE права_колонка IS NULL; у плані це вузол Anti Join чи antijoin.
02 · ON ЧИ WHERE

Умова на праву таблицю

Класична пастка. Треба показати всі компанії з їхніми активними вакансіями, і компанії без активних теж. Пишемо LEFT JOIN, а умову на статус кладемо в WHERE. У компанії без пари v.status це NULL, NULL = 'active' дає не TRUE, а NULL, і WHERE відкидає рядок. LEFT JOIN тихо перетворився на INNER, і PostgreSQL навіть сам перебудовує його на звичайний JOIN, бо бачить, що рядки з NULL однаково не пройдуть.

Правило: умови, які вирішують, що вважати парою, ідуть в ON. У WHERE лишаються умови на ліву таблицю і перевірка IS NULL, коли рядки без пари потрібні навмисно.

companies c LEFT JOIN vacancies v ON v.company_id = c.id
... 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 на даних стенда з головної розділу і ще одній компанії, у якої є лише закрита вакансія.

03 · FULL JOIN

FULL JOIN, якого в MySQL немає

FULL JOIN потрібен рідко: звірити дві таблиці й побачити, чого бракує з кожного боку. PostgreSQL і SQLite з версії 3.39 вміють його прямо. MySQL такого синтаксису не має, і FULL JOIN збирають з двох половин: LEFT JOIN дає все зліва, RIGHT JOIN з умовою c.id IS NULL додає лише праві рядки без пари.

Половини склеюють через UNION ALL, а не через UNION. UNION прибирає однакові рядки, а дві різні вакансії з однаковою назвою в одній компанії це два законні рядки результату.

PostgreSQL 16 MySQL 8.0 SQLite 3.45
1SELECT c.name, v.title
2FROM companies c
3FULL JOIN vacancies v ON v.company_id = c.id
4ORDER BY c.id, v.id;
    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 при сортуванні за зростанням іде в кінці, тому вакансія без компанії остання.

1SELECT c.name, v.title
2FROM companies c
3LEFT JOIN vacancies v ON v.company_id = c.id
4UNION ALL
5SELECT c.name, v.title
6FROM companies c
7RIGHT JOIN vacancies v ON v.company_id = c.id
8WHERE c.id IS 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.

1SELECT c.name, v.title
2FROM companies c
3FULL JOIN vacancies v ON v.company_id = c.id
4ORDER BY c.id, v.id;
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, тому вакансія без компанії тут угорі.

04 · UPDATE І DELETE З JOIN

Оновити рядки за умовою з іншої таблиці

Закрити всі активні вакансії компанії Kvitka Soft, а потім видалити відгуки на закриті вакансії. Умова лежить в іншій таблиці, і кожна база пише це по-своєму: MySQL дозволяє JOIN прямо в UPDATE і DELETE, PostgreSQL бере другу таблицю через FROM в UPDATE і через USING у DELETE. SQLite знає UPDATE ... FROM з версії 3.33, а DELETE з JOIN не вміє зовсім.

MySQL 8.0 PostgreSQL 16 SQLite 3.45
1UPDATE vacancies v
2JOIN companies c ON c.id = v.company_id
3SET v.status = 'closed'
4WHERE c.name = 'Kvitka Soft'
5 AND v.status = 'active';
6
7DELETE a FROM applications a
8JOIN vacancies v ON v.id = a.vacancy_id
9WHERE v.status = 'closed';
Query OK, 2 rows affected
Query OK, 4 rows affected

У DELETE з JOIN після слова DELETE називають аліаси таблиць, з яких видаляти: тут лише a, вакансії лишаються.

1UPDATE vacancies v
2SET status = 'closed'
3FROM companies c
4WHERE c.id = v.company_id
5 AND c.name = 'Kvitka Soft'
6 AND v.status = 'active';
7
8DELETE FROM applications a
9USING vacancies v
10WHERE v.id = a.vacancy_id
11 AND v.status = 'closed';
UPDATE 2
DELETE 4

Умова зʼєднання тут у WHERE. Якщо її забути, кожен рядок vacancies отримає пару з кожною компанією, тобто CROSS JOIN.

1UPDATE vacancies AS v
2SET status = 'closed'
3FROM companies AS c
4WHERE c.id = v.company_id
5 AND c.name = 'Kvitka Soft'
6 AND v.status = 'active';
7
8DELETE FROM applications
9WHERE vacancy_id IN (
10 SELECT id FROM vacancies
11 WHERE status = 'closed');
changes() = 2
changes() = 4
-- DELETE ... USING:
Parse error: near "USING": syntax error

UPDATE ... FROM у SQLite такий самий, як у PostgreSQL. Для DELETE лишається підзапит у WHERE, і він працює в усіх трьох базах.

05 · ANTI-JOIN У ПЛАНІ

Компанії без вакансій, три записи

Компанії без жодної вакансії знаходять через NOT EXISTS або через LEFT JOIN з IS NULL. Результат однаковий, 1 180 компаній з 10 000. План залежить від того, яку колонку ви перевіряєте на NULL. NOT EXISTS обидві бази перетворюють на anti-join завжди. LEFT JOIN з IS NULL PostgreSQL перетворює, коли перевіряють колонку з умови ON, а MySQL, коли перевірена колонка оголошена NOT NULL. Тому в однієї бази швидкий один запис, а в іншої інший.

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
IS NULL на колонці з ONSELECT 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
IS NULL на v.idSELECT 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 прибрано.

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)
         (actual time=0.00461..0.00461 rows=0.882 loops=10000)
IS NULL на v.idSELECT 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)
IS NULL на колонці з ONSELECT 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 означає, що для частини компаній вакансій не знайшлося.
06 · JOIN В ELOQUENT

Що Eloquent пише замість вас

join() і leftJoin() дають справжній JOIN, і рядки множаться так само, як у SQL. whereHas() і withCount() JOIN не пишуть зовсім: вони будують підзапит, тож компанія з трьома вакансіями лишається одним рядком. Нижче SQL, який Laravel 13 збирає для PostgreSQL для моделі Company зі звʼязком jobs на таблицю vacancies; лапки біля назв прибрано. Якщо в моделі вакансії є глобальний scope, його умова теж опиниться всередині підзапиту.

php CompanyQueries.php join()
1DB::table('companies')
2 ->join('vacancies', 'vacancies.company_id', '=', 'companies.id')
3 ->select('companies.name', 'vacancies.title')
4 ->get();
5
6// select companies.name, vacancies.title from companies
7// inner join vacancies on vacancies.company_id = companies.id
ЩО ПЕРЕВІРЯЄ
Звичайний INNER JOIN. Kvitka Soft прийде тричі, Lviv Pay не прийде.
КОЛИ БРАТИ
Коли потрібні колонки обох таблиць в одному рядку: список вакансій з назвою компанії, звіт.
php CompanyQueries.php leftJoin() з умовою
1DB::table('companies')
2 ->leftJoin('vacancies', fn (JoinClause $join) => $join
3 ->on('vacancies.company_id', '=', 'companies.id')
4 ->where('vacancies.status', '=', 'active'))
5 ->select('companies.name', 'vacancies.title')
6 ->get();
7
8// left join vacancies on vacancies.company_id = companies.id
9// and vacancies.status = ?
ЩО ПЕРЕВІРЯЄ
Замикання збирає умову ON. where() всередині нього йде в ON, а не в WHERE, тож LEFT JOIN лишається LEFT.
КОЛИ БРАТИ
Коли умова на праву таблицю вирішує, що вважати парою, і рядки без пари мають лишитися.
php CompanyQueries.php whereHas()
1Company::query()
2 ->whereHas('jobs', fn (Builder $q) => $q->where('status', 'active'))
3 ->get();
4
5// select * from companies where exists (
6// select * from vacancies
7// where companies.id = vacancies.company_id and status = ?)
ЩО ПЕРЕВІРЯЄ
EXISTS з корельованим підзапитом. Кожна компанія приходить один раз, скільки б активних вакансій у неї не було.
КОЛИ БРАТИ
Коли треба відібрати батьківські рядки за дочірніми, а самі дочірні в результаті не потрібні.
php CompanyQueries.php whereDoesntHave()
1Company::query()
2 ->whereDoesntHave('jobs')
3 ->get();
4
5// select * from companies where not exists (
6// select * from vacancies
7// where companies.id = vacancies.company_id)
ЩО ПЕРЕВІРЯЄ
Anti-join через NOT EXISTS, той самий швидкий запис, що в плані вище.
КОЛИ БРАТИ
Компанії без вакансій, користувачі без замовлень, статті без коментарів.
php CompanyQueries.php withCount()
1Company::query()
2 ->withCount(['jobs as open_jobs' => fn (Builder $q) => $q->where('status', 'active')])
3 ->get();
4
5// select companies.*, (select count(*) from vacancies
6// where companies.id = vacancies.company_id
7// and status = ?) as open_jobs from companies
ЩО ПЕРЕВІРЯЄ
Підзапит у SELECT, а не JOIN з GROUP BY. Lviv Pay отримає open_jobs = 0, а не NULL.
КОЛИ БРАТИ
Коли поруч з рядком потрібне число дочірніх рядків. Кілька withCount на різні звʼязки не множать один одного, на відміну від двох JOIN.
07 · ЯК У PHP UKRAINE

LEFT JOIN у пошуку сайту

Пошук вакансій шукає слова і в назві вакансії, і в назві компанії, тож компанію приєднано через leftJoin(). LEFT тут обовʼязковий: company_id у vacancies може бути NULL, і вакансія без компанії через звичайний JOIN зникла б з пошуку. select(vacancies.*) потрібен, бо в обох таблицях є id, і без нього id компанії перезаписав би id вакансії в моделі. Умова на companies.name стоїть у WHERE, але не ламає LEFT JOIN: terms() кладе її в одну OR-групу з колонками вакансії, тож вакансія без компанії проходить за назвою.

php EloquentSearchEngine.php фрагмент, скорочено
1$builder = JobModel::query()->visible()->with('company')
2 ->select($jobs.'.*')← id вакансії не перезапишеться
3 ->leftJoin($companies, $companies.'.id', '=', $jobs.'.company_id')← вакансії без компанії лишаються
4 ->orderByDesc($jobs.'.published_at');
5
6$this->terms($builder, [$jobs.'.title', $jobs.'.excerpt', $jobs.'.raw_description', $companies.'.name'], $query->terms);← OR-група, LEFT не ламається
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. Вакансія з трьома містами свідомо дає три рядки, і кожне місто отримує свій плюс один.
08 · ДЕ JOIN ЛАМАЄТЬСЯ

Зайві рядки і зниклі рядки

✕ 1

Умова на праву таблицю в WHERE

LEFT JOIN vacancies і WHERE v.status = 'active' прибирають компанії без активних вакансій. Помилку не видно, поки в даних немає такої компанії.

Натомість: Переносьте умову в ON, а в Eloquent в замикання leftJoin(). У WHERE для правої таблиці лишайте тільки IS NULL.

✕ 2

Два 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 не лікується, бо дві різні вакансії можуть мати однакову зарплату.

✕ 3

SELECT * з JOIN

В обох таблицях є id, name, created_at. Драйвер повертає рядок з однаковими ключами, і в масиві чи моделі одна колонка мовчки перезаписує іншу.

Натомість: Перелічуйте колонки з аліасами (c.name AS company_name), а в Eloquent пишіть select('vacancies.*') перед join().

✕ 4

Забута умова зʼєднання

FROM companies, vacancies без WHERE чи JOIN без ON (MySQL це дозволяє) це CROSS JOIN. На тестових трьох рядках непомітно, на продакшні мільйони рядків.

Натомість: Пишіть JOIN ... ON замість переліку таблиць через кому. Явний CROSS JOIN лишайте для випадків, коли декартів добуток і потрібен.

✕ 5

JOIN заради перевірки існування

JOIN vacancies лише для того, щоб відібрати компанії з вакансіями, дає дублі компаній. Їх прибирають DISTINCT, і база сортує чи хешує весь результат.

Натомість: Для «має хоча б одну» беріть EXISTS (whereHas() в Eloquent): він зупиняється на першій знайденій парі й не множить рядки.

09 · СЛОВНИК
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.
10 · ПЕРЕВІР СЕБЕ

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

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

Від 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, а потім зʼєднуйте вже агреговані результати, по одному рядку на компанію з кожного боку.