Пошук уроків, статей та іншого контенту
Порівняєте JOIN і підзапити та оберете відповідний підхід для читабельних і ефективних запитів.
JOIN і підзапит часто дають змогу отримати однаковий результат, але виражають різні наміри:
JOIN поєднує рядки з кількох джерел в одному результуючому наборі.
підзапит обчислює проміжний результат або перевіряє умову всередині іншого запиту.
Вибір між ними варто робити не за правилом «підзапити повільніші», а за такими критеріями:
Який результат потрібно отримати?
Чи може один рядок основної таблиці відповідати кільком рядкам іншої?
Чи потрібні стовпці з іншої таблиці?
Чи перевіряється лише факт існування пов’язаного рядка?
Наскільки легко запит читати й перевіряти?
У PostgreSQL оптимізатор часто перетворює еквівалентні форми запитів на подібні плани виконання. Проте різниця в семантиці все одно залишається важливою: некоректно вибраний тип JOIN може дублювати рядки або змінювати результат.
Нехай є користувачі, замовлення та товари:
DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS products;
DROP TABLE IF EXISTS customers;
CREATE TEMP TABLE customers (
customer_id integer PRIMARY KEY,
full_name text NOT NULL,
country text NOT NULL
);
CREATE TEMP TABLE orders (
order_id integer PRIMARY KEY,
customer_id integer NOT NULL REFERENCES customers(customer_id),
ordered_at date NOT NULL,
status text NOT NULL
);
CREATE TEMP TABLE products (
product_id integer PRIMARY KEY,
product_name text NOT NULL,
price numeric(10, 2) NOT NULL
);
CREATE TEMP TABLE order_items (
order_id integer NOT NULL REFERENCES orders(order_id),
product_id integer NOT NULL REFERENCES products(product_id),
quantity integer NOT NULL CHECK (quantity > 0),
PRIMARY KEY (order_id, product_id)
);
INSERT INTO customers (customer_id, full_name, country)
VALUES
(1, 'Олена Коваль', 'UA'),
(2, 'Андрій Мельник', 'UA'),
(3, 'Marta Nowak', 'PL'),
(4, 'John Smith', 'US');
INSERT INTO orders (order_id, customer_id, ordered_at, status)
VALUES
(101, 1, DATE '2026-01-10', 'paid'),
(102, 1, DATE '2026-02-15', 'cancelled'),
(103, 2, DATE '2026-02-20', 'paid'),
(104, 3, DATE '2026-02-21', 'paid');
INSERT INTO products (product_id, product_name, price)
VALUES
(10, 'Клавіатура', 80.00),
(11, 'Монітор', 300.00),
(12, 'Миша', 40.00);
INSERT INTO order_items (order_id, product_id, quantity)
VALUES
(101, 10, 1),
(101, 12, 2),
(102, 11, 1),
(103, 11, 2),
(104, 12, 1);JOINJOIN доречний, коли потрібно показати дані з обох таблиць або сформувати рядки на основі їхнього поєднання.
Наприклад, потрібно показати кожне оплачене замовлення разом з іменем клієнта:
SELECT
o.order_id,
o.ordered_at,
c.full_name,
c.country
FROM orders AS o
JOIN customers AS c
ON c.customer_id = o.customer_id
WHERE o.status = 'paid'
ORDER BY o.order_id;Результат міститиме по одному рядку на замовлення:
order_id | ordered_at | full_name | country
----------+------------+--------------+---------
101 | 2026-01-10 | Олена Коваль | UA
103 | 2026-02-20 | Андрій Мельник | UA
104 | 2026-02-21 | Marta Nowak | PLТут JOIN читається природно: до кожного замовлення приєднується його клієнт.
Щоб отримати склад кожного оплаченого замовлення:
SELECT
o.order_id,
c.full_name,
p.product_name,
oi.quantity,
p.price,
oi.quantity * p.price AS item_total
FROM orders AS o
JOIN customers AS c
ON c.customer_id = o.customer_id
JOIN order_items AS oi
ON oi.order_id = o.order_id
JOIN products AS p
ON p.product_id = oi.product_id
WHERE o.status = 'paid'
ORDER BY o.order_id, p.product_id;У цьому випадку підзапит не зробив би запит зрозумілішим: потрібно показати поля з чотирьох пов’язаних таблиць, тому ланцюжок JOIN є відповідною моделлю.
JOIN: дублювання рядківJOIN не просто перевіряє зв’язок. Він створює рядок для кожної відповідної комбінації.
Визначимо клієнтів, які мають оплачені замовлення:
SELECT
c.customer_id,
c.full_name
FROM customers AS c
JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.status = 'paid'
ORDER BY c.customer_id;Якщо в клієнта кілька оплачених замовлень, він з’явиться кілька разів. У наших даних це поки не видно для клієнта 1, але додамо ще одне оплачене замовлення:
INSERT INTO orders (order_id, customer_id, ordered_at, status)
VALUES (105, 1, DATE '2026-03-01', 'paid');Тепер клієнт Олена Коваль з’явиться двічі.
Можна використати DISTINCT:
SELECT DISTINCT
c.customer_id,
c.full_name
FROM customers AS c
JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.status = 'paid'
ORDER BY c.customer_id;Але DISTINCT лише прибирає наслідок дублювання. Якщо нам не потрібні дані замовлень, краще виразити умову як перевірку існування.
EXISTSEXISTS відповідає на запитання:
Чи існує хоча б один рядок, який задовольняє умову?
Для клієнтів з оплаченими замовленнями:
SELECT
c.customer_id,
c.full_name
FROM customers AS c
WHERE EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.customer_id
AND o.status = 'paid'
)
ORDER BY c.customer_id;Підзапит корельований: він використовує c.customer_id із зовнішнього запиту.
SELECT 1 тут не означає, що PostgreSQL обов’язково обчислює число 1 для кожного рядка. Для EXISTS важливий сам факт наявності рядка, а не значення, яке вибирає підзапит.
EXISTS кращий за JOIN для перевірки існуванняПорівняймо наміри:
-- Поєднати клієнтів із замовленнями
SELECT DISTINCT c.customer_id, c.full_name
FROM customers AS c
JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.status = 'paid';
-- Перевірити, чи має клієнт хоча б одне оплачене замовлення
SELECT c.customer_id, c.full_name
FROM customers AS c
WHERE EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.customer_id
AND o.status = 'paid'
);Другий варіант точніше описує задачу. Він також не створює проміжних дублікатів клієнта через кілька замовлень.
PostgreSQL може оптимізувати EXISTS як напівз’єднання — операцію, яка повертає рядок зовнішньої таблиці максимум один раз. Але головна перевага тут — коректна семантика й читабельність.
NOT EXISTS для відсутності пов’язаних рядківЩоб знайти клієнтів, які не мають оплачених замовлень:
SELECT
c.customer_id,
c.full_name
FROM customers AS c
WHERE NOT EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.customer_id
AND o.status = 'paid'
)
ORDER BY c.customer_id;NOT EXISTS зазвичай є найнадійнішим способом перевірити відсутність пов’язаних рядків.
Він особливо корисний для умов типу:
клієнти без замовлень;
товари, які ще не продавалися;
користувачі без активної підписки;
проєкти, у яких немає відкритих задач.
LEFT JOIN ... IS NULL і NOT EXISTSТаку саму задачу можна записати через зовнішнє з’єднання:
SELECT
c.customer_id,
c.full_name
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.status = 'paid'
WHERE o.order_id IS NULL
ORDER BY c.customer_id;Обидва варіанти можуть бути коректними. Однак NOT EXISTS безпосередньо виражає умову «не існує жодного рядка», тому часто є зрозумілішим.
Важливо: фільтр для правої таблиці має бути в ON, а не в WHERE.
Правильно:
SELECT c.customer_id, c.full_name
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.status = 'paid'
WHERE o.order_id IS NULL;Некоректно для цієї задачі:
SELECT c.customer_id, c.full_name
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.status = 'paid'
AND o.order_id IS NULL;У другому варіанті один і той самий рядок o не може одночасно мати status = 'paid' і бути NULL. Такий запит не поверне очікуваний результат.
Скалярний підзапит повертає одне значення, яке можна використати як вираз.
Наприклад, для кожного замовлення можна обчислити його суму:
SELECT
o.order_id,
o.customer_id,
(
SELECT COALESCE(SUM(oi.quantity * p.price), 0)
FROM order_items AS oi
JOIN products AS p
ON p.product_id = oi.product_id
WHERE oi.order_id = o.order_id
) AS order_total
FROM orders AS o
ORDER BY o.order_id;Це читабельно, коли обчислене значення є властивістю одного рядка зовнішнього запиту.
Той самий результат через попередню агрегацію та JOIN:
SELECT
o.order_id,
o.customer_id,
COALESCE(t.order_total, 0) AS order_total
FROM orders AS o
LEFT JOIN (
SELECT
oi.order_id,
SUM(oi.quantity * p.price) AS order_total
FROM order_items AS oi
JOIN products AS p
ON p.product_id = oi.product_id
GROUP BY oi.order_id
) AS t
ON t.order_id = o.order_id
ORDER BY o.order_id;Скалярний підзапит має повернути не більше одного рядка. Якщо він поверне кілька рядків, PostgreSQL завершить запит помилкою:
ERROR: more than one row returned by a subquery used as an expressionТому цей запит небезпечний:
SELECT
c.full_name,
(
SELECT o.order_id
FROM orders AS o
WHERE o.customer_id = c.customer_id
) AS order_id
FROM customers AS c;Клієнт може мати кілька замовлень. Якщо потрібне одне конкретне замовлення, умова має це гарантувати, наприклад через агрегат або ORDER BY ... LIMIT 1:
SELECT
c.full_name,
(
SELECT o.order_id
FROM orders AS o
WHERE o.customer_id = c.customer_id
ORDER BY o.ordered_at DESC, o.order_id DESC
LIMIT 1
) AS latest_order_id
FROM customers AS c
ORDER BY c.customer_id;Тут результатом для клієнта буде одне останнє замовлення або NULL, якщо замовлень немає.
FROMПідзапит у FROM створює проміжний набір даних, до якого можна звертатися як до таблиці.
Наприклад, спочатку обчислимо кількість оплачених замовлень для кожного клієнта, а потім залишимо лише клієнтів із двома або більше замовленнями:
SELECT
c.full_name,
paid_orders.order_count
FROM customers AS c
JOIN (
SELECT
customer_id,
COUNT(*) AS order_count
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
HAVING COUNT(*) >= 2
) AS paid_orders
ON paid_orders.customer_id = c.customer_id
ORDER BY paid_orders.order_count DESC, c.full_name;Такий підхід корисний, коли підзапит має власну логічну стадію:
відфільтрувати замовлення;
згрупувати їх;
застосувати HAVING;
приєднати агрегований результат до інших даних.
JOIN чи підзапит для фільтрації?Розгляньмо задачу: знайти клієнтів, у яких сума оплачених замовлень перевищує 100.
Варіант із JOIN та агрегацією:
SELECT
c.customer_id,
c.full_name,
SUM(oi.quantity * p.price) AS paid_total
FROM customers AS c
JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.status = 'paid'
JOIN order_items AS oi
ON oi.order_id = o.order_id
JOIN products AS p
ON p.product_id = oi.product_id
GROUP BY c.customer_id, c.full_name
HAVING SUM(oi.quantity * p.price) > 100
ORDER BY paid_total DESC;Варіант із підзапитом у WHERE:
SELECT
c.customer_id,
c.full_name
FROM customers AS c
WHERE (
SELECT COALESCE(SUM(oi.quantity * p.price), 0)
FROM orders AS o
JOIN order_items AS oi
ON oi.order_id = o.order_id
JOIN products AS p
ON p.product_id = oi.product_id
WHERE o.customer_id = c.customer_id
AND o.status = 'paid'
) > 100
ORDER BY c.customer_id;Перший варіант зручніший, якщо потрібно показати саму суму. Другий може бути доречним, якщо сума є лише умовою фільтрації.
У складніших запитах краще не дублювати довгі вирази. Проміжний агрегований набір через FROM або WITH часто робить структуру зрозумілішою.
IN і EXISTSПідзапит із IN перевіряє, чи належить значення набору:
SELECT
c.customer_id,
c.full_name
FROM customers AS c
WHERE c.customer_id IN (
SELECT o.customer_id
FROM orders AS o
WHERE o.status = 'paid'
)
ORDER BY c.customer_id;Для простої перевірки існування це можна записати через EXISTS:
SELECT
c.customer_id,
c.full_name
FROM customers AS c
WHERE EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.customer_id
AND o.status = 'paid'
)
ORDER BY c.customer_id;Для звичайних ненульових ідентифікаторів ці форми часто еквівалентні. Проте EXISTS краще передає намір корельованої перевірки.
NOT IN і NULLNOT IN має особливу поведінку, якщо підзапит повертає NULL.
Наприклад:
SELECT 5 NOT IN (1, 2, NULL);Результат — NULL, а не TRUE. У WHERE це означає, що рядок не пройде фільтр.
Тому для умови «значення не має відповідного рядка» зазвичай безпечніше використовувати NOT EXISTS:
SELECT
c.customer_id,
c.full_name
FROM customers AS c
WHERE NOT EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.customer_id
AND o.status = 'cancelled'
)
ORDER BY c.customer_id;Якщо NOT IN усе ж використовується, потрібно явно гарантувати, що підзапит не повертає NULL.
ALL і ANYПідзапити можуть порівнювати значення з набором рядків.
> ALL означає «більше за кожне значення»:
SELECT
p.product_id,
p.product_name,
p.price
FROM products AS p
WHERE p.price > ALL (
SELECT p2.price
FROM products AS p2
WHERE p2.product_id <> p.product_id
)
ORDER BY p.price DESC;> ANY означає «більше хоча б за одне значення»:
SELECT
p.product_id,
p.product_name,
p.price
FROM products AS p
WHERE p.price > ANY (
SELECT p2.price
FROM products AS p2
WHERE p2.product_id <> p.product_id
)
ORDER BY p.price DESC;Такі конструкції корисні для порівняння з набором, але їх варто застосовувати лише тоді, коли логіка справді описується словами «кожне» або «хоча б одне». Для простої перевірки наявності краще EXISTS.
Некорельований підзапит не посилається на зовнішній запит:
SELECT
c.customer_id,
c.full_name
FROM customers AS c
WHERE c.customer_id IN (
SELECT o.customer_id
FROM orders AS o
WHERE o.status = 'paid'
);Його результат не залежить від поточного рядка customers.
Корельований підзапит використовує значення із зовнішнього рядка:
SELECT
c.customer_id,
c.full_name
FROM customers AS c
WHERE EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.customer_id
AND o.status = 'paid'
);Корельовані підзапити часто є найчитабельнішими для EXISTS. Але скалярні корельовані підзапити зі складними обчисленнями потрібно перевіряти на великих наборах даних: їхня наївна форма може виглядати як повторне виконання логіки для кожного рядка.
Це не означає, що корельований підзапит обов’язково повільний. PostgreSQL може оптимізувати його, а наявність відповідного індексу може зробити перевірку ефективною. Реальний план потрібно перевіряти за допомогою EXPLAIN.
Для перевірки плану виконання використовуйте:
EXPLAIN (ANALYZE, BUFFERS)
SELECT
c.customer_id,
c.full_name
FROM customers AS c
WHERE EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.customer_id
AND o.status = 'paid'
);Основні елементи, на які варто звертати увагу:
фактичний час виконання;
кількість фактично оброблених рядків;
кількість запусків вузла (loops);
зайві послідовні сканування великих таблиць;
кількість операцій читання з буфера;
чи відповідає фактична кількість рядків оцінці планувальника.
Порівнювати потрібно запити:
з однаковим результатом;
на однакових даних;
у подібних умовах;
після прогрівання або повторного запуску, якщо це важливо для вимірювання.
Не варто робити висновок лише з текстової форми запиту. JOIN не завжди швидший за підзапит, а підзапит не завжди створює окремий повний прохід по таблиці.
JOIN, якщо:потрібно вивести стовпці з пов’язаної таблиці;
результат природно складається з поєднаних рядків;
потрібно виконати агрегацію за даними кількох таблиць;
потрібно отримати всі відповідні комбінації рядків;
зв’язок між наборами є центральною частиною результату.
EXISTS, якщо:потрібно лише перевірити наявність пов’язаного рядка;
дублювання рядків є небажаним;
потрібно перевірити відсутність пов’язаних рядків через NOT EXISTS;
умова стосується кожного рядка зовнішньої таблиці.
потрібно додати одне обчислене значення до рядка;
підзапит гарантовано повертає максимум один рядок;
локальне обчислення зрозуміліше за окрему агрегацію з JOIN.
FROM, якщо:потрібно спочатку сформувати агрегований або відфільтрований набір;
цей набір має власну логічну роль;
поділ на етапи покращує читабельність запиту.
JOIN замість перевірки існуванняЯкщо потрібен список клієнтів, JOIN до таблиці замовлень може дублювати клієнтів. Не додавайте DISTINCT автоматично — спочатку перевірте, чи не потрібен EXISTS.
LEFT JOIN на INNER JOINФільтр правої таблиці в WHERE може знищити рядки без відповідності:
-- Фактично поводиться як INNER JOIN
SELECT c.full_name, o.order_id
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.status = 'paid';Якщо потрібно зберегти клієнтів без замовлень, фільтр треба розміщувати в ON або використовувати окрему умову з EXISTS.
NOT IN без перевірки на NULLОдин NULL у результаті підзапиту може змінити логіку всього предиката. Для запитів на відсутність рядків обирайте NOT EXISTS.
Скалярний підзапит повинен містити гарантію одного результату: агрегат, унікальну умову або LIMIT 1 із визначеним ORDER BY.
Кількість рядків у SQL-тексті не визначає продуктивність. Перевіряйте фактичний план через EXPLAIN (ANALYZE, BUFFERS) і переконайтеся, що порівнювані запити справді повертають однакові дані.
Підзапит має ізолювати логічний етап, а не ускладнювати його. Якщо вкладеність стала значною, розділіть обчислення на зрозумілі етапи або використайте явний JOIN з агрегованим набором.
JOIN поєднує рядки та може створювати кілька результатів для одного рядка зовнішньої таблиці.
EXISTS перевіряє факт наявності й не дублює рядки зовнішнього запиту.
NOT EXISTS — надійний спосіб перевірити відсутність пов’язаних рядків.
Скалярний підзапит підходить для одного обчисленого значення, але має повернути максимум один рядок.
Підзапит у FROM зручний для проміжної фільтрації та агрегації.
NOT IN потребує особливої уваги до NULL.
PostgreSQL може оптимізувати різні форми запитів подібним чином, але семантику запиту потрібно визначити першою.
Для тверджень про продуктивність використовуйте EXPLAIN (ANALYZE, BUFFERS), а не припущення про те, що JOIN або підзапити завжди швидші.