Baza wiedzy / SQL
LEFT JOIN w SQL: składnia, przykłady i pułapki
Pokazuję, które wiersze zachowuje LEFT JOIN, dlaczego jeden warunek w WHERE potrafi po cichu zamienić go w INNER JOIN i jak sprawdzam, czy złączenie nie pomnożyło mi wierszy.

LEFT JOIN przydaje się, gdy mam listę rzeczy, które muszą zostać w wyniku, na przykład wszystkie produkty z katalogu, i chcę dołożyć do nich dane z drugiej tabeli, na przykład sprzedaż. Produkt bez sprzedaży nie znika. Dostaje wiersz, w którym kolumny z drugiej tabeli mają wartość NULL.
Brzmi prosto i takie jest, dopóki nie dojdą filtry, duplikaty i szukanie braków. Opisuję je poniżej na jednym małym zbiorze. Porównanie wszystkich rodzajów złączeń na danych klientów i zamówień znajdziesz w artykule Rodzaje JOIN w SQL: jak łączę tabele i pilnuję wyniku. Tutaj zajmuję się wyłącznie LEFT JOIN.
Składnia LEFT JOIN
SELECT kolumny
FROM tabela_lewa AS l
LEFT JOIN tabela_prawa AS r
ON r.klucz = l.klucz;
- Lewa tabela stoi po
FROM. Każdy jej wiersz trafi do wyniku co najmniej raz. - Prawa tabela stoi po
LEFT JOIN. Jej wiersze trafiają do wyniku tylko wtedy, gdy spełniają warunek zON. - Gdy dla wiersza z lewej nic nie pasuje, baza i tak go zwraca, a kolumny prawej tabeli wypełnia wartością
NULL.
LEFT OUTER JOIN oznacza dokładnie to samo, słowo OUTER jest opcjonalne. Dokumentacja PostgreSQL opisuje mechanizm w dwóch krokach: najpierw powstaje zwykłe złączenie wewnętrzne, potem baza dopisuje każdy wiersz lewej tabeli, który nie pasował do żadnego wiersza prawej.
Klucze porównuję w tym samym typie. PostgreSQL odrzuci porównanie kolumny integer z kolumną text błędem operator does not exist. SQLite zamieni tekst na liczbę według reguł powinowactwa typów, więc do liczby 1 dopasuje także teksty '01' i ' 1'. Rzutowanie przez CAST robię więc jawnie i w jednym miejscu.
Przykład: produkty i ich sprzedaż
Pracuję na małym, syntetycznym zbiorze: cztery produkty i sześć transakcji. Kubek (mug) sprzedał się trzy razy, notes raz, lampa tylko we wrześniu, a plecak ani razu. Transakcja 6 nie ma identyfikatora produktu, bo w eksportach takie rekordy się zdarzają. Kod działa bez zmian w SQLite i PostgreSQL.
-- example: fixture
CREATE TEMP TABLE products (
product_id INTEGER PRIMARY KEY,
product_name TEXT NOT NULL
);
CREATE TEMP TABLE sales (
sale_id INTEGER PRIMARY KEY,
product_id INTEGER,
sale_date DATE NOT NULL,
quantity INTEGER NOT NULL
);
INSERT INTO products VALUES
(1, 'mug'), (2, 'notebook'), (3, 'lamp'), (4, 'backpack');
INSERT INTO sales VALUES
(1, 1, '2026-09-28', 2),
(2, 1, '2026-10-03', 1),
(3, 1, '2026-10-15', 3),
(4, 2, '2026-10-07', 5),
(5, 3, '2026-09-12', 1),
(6, NULL, '2026-10-09', 2);
Chcę zobaczyć wszystkie produkty razem z ich sprzedażą:
-- example: basic
SELECT p.product_id, p.product_name, s.sale_id, s.sale_date, s.quantity
FROM products AS p
LEFT JOIN sales AS s ON s.product_id = p.product_id
ORDER BY p.product_id, s.sale_id;
| product_id | product_name | sale_id | sale_date | quantity |
|---|---|---|---|---|
| 1 | mug | 1 | 2026-09-28 | 2 |
| 1 | mug | 2 | 2026-10-03 | 1 |
| 1 | mug | 3 | 2026-10-15 | 3 |
| 2 | notebook | 4 | 2026-10-07 | 5 |
| 3 | lamp | 5 | 2026-09-12 | 1 |
| 4 | backpack | NULL | NULL | NULL |
Sześć wierszy z czterech produktów. Plecak został, choć nie ma żadnej sprzedaży, a kubek występuje trzy razy, bo ma trzy pasujące transakcje. LEFT JOIN obiecuje, że każdy produkt pojawi się co najmniej raz. Nie obiecuje, że dokładnie raz.
Transakcji 6 w wyniku nie ma. Jej product_id jest pusty, a porównanie NULL = 1 nie daje prawdy, więc nie pasuje do żadnego produktu. Zachowuję wiersze lewej strony, a lewą stroną są tu produkty. Gdybym zaczął od FROM sales i dołączył products, dostałbym sześć wierszy sprzedaży, a transakcja 6 miałaby pustą nazwę produktu. Kolejność tabel ustalam więc od pytania, czego nie wolno mi zgubić.
ORDER BY dopisuję zawsze. Bez niego baza może zwrócić wiersze w dowolnej kolejności, a wtedy porównywanie wyników na oko traci sens.
Warunek w ON a warunek w WHERE
Teraz chcę wszystkie produkty i tylko sprzedaż z października. Warunek daty dopisuję do ON, bo opisuje, która sprzedaż ma pasować:
-- example: october-on
SELECT p.product_id, p.product_name, s.sale_id
FROM products AS p
LEFT JOIN sales AS s
ON s.product_id = p.product_id
AND s.sale_date >= '2026-10-01'
AND s.sale_date < '2026-11-01'
ORDER BY p.product_id, s.sale_id;
Pięć wierszy: kubek z transakcjami 2 i 3, notes z transakcją 4, a lampa i plecak z NULL. Lampa sprzedała się we wrześniu, ale wrześniowa transakcja nie spełnia warunku dopasowania, więc lampa zostaje bez pary.
Ten sam warunek w WHERE zadaje inne pytanie:
-- example: october-where
SELECT p.product_id, p.product_name, s.sale_id
FROM products AS p
LEFT JOIN sales AS s ON s.product_id = p.product_id
WHERE s.sale_date >= '2026-10-01'
AND s.sale_date < '2026-11-01'
ORDER BY p.product_id, s.sale_id;
Trzy wiersze. Lampa i plecak zniknęły, każde z innego powodu. WHERE działa na wyniku złączenia. Lampa ma tylko wrześniową sprzedaż, więc jej wiersz odpada na warunku daty. Plecak nie ma sprzedaży, a w dopisanym dla niego wierszu s.sale_date ma wartość NULL. Porównanie NULL >= '2026-10-01' daje wynik nieznany, a WHERE przepuszcza wyłącznie prawdę. W praktyce dostałem INNER JOIN przebrany za LEFT JOIN.
Pułapka dotyczy warunków na kolumnach prawej tabeli. Filtr lewej tabeli, na przykład WHERE p.product_name <> 'lamp', może spokojnie zostać w WHERE, bo usuwa produkty, a dopasowań nie dotyka. Wyjątkiem po prawej stronie są warunki prawdziwe dla NULL, przede wszystkim IS NULL, do którego wrócę przy szukaniu braków. Z tego samego powodu rada „filtruj w WHERE przed złączeniem” myli kolejność. WHERE filtruje wynik złączenia. Prawą tabelę zawężam w ON albo w podzapytaniu po prawej stronie.
Przy datach pilnuję jeszcze granicy. Piszę sale_date < '2026-11-01' zamiast <= '2026-10-31'. Jeśli kolumna przechowuje także godzinę, druga wersja zgubi sprzedaż z 31 października zapisaną na przykład o 14:30.
Liczenie braków: COUNT i COALESCE
Z tego samego złączenia robię podsumowanie października:
-- example: october-summary
SELECT p.product_id, p.product_name,
COUNT(s.sale_id) AS sales_count,
COALESCE(SUM(s.quantity), 0) AS units
FROM products AS p
LEFT JOIN sales AS s
ON s.product_id = p.product_id
AND s.sale_date >= '2026-10-01'
AND s.sale_date < '2026-11-01'
GROUP BY p.product_id, p.product_name
ORDER BY p.product_id;
| product_id | product_name | sales_count | units |
|---|---|---|---|
| 1 | mug | 2 | 4 |
| 2 | notebook | 1 | 5 |
| 3 | lamp | 0 | 0 |
| 4 | backpack | 0 | 0 |
Liczę COUNT(s.sale_id), czyli niepuste identyfikatory sprzedaży. COUNT(*) liczy wiersze, więc lampie i plecakowi przypisałby po jednej sprzedaży: tej, którą dopisał LEFT JOIN. SUM z samych wartości NULL zwraca NULL, dlatego COALESCE zamienia wynik na zero. Tu zero jest prawdziwą odpowiedzią: w październiku nie sprzedała się ani jedna lampa.
COALESCE nie wstawiam jednak odruchowo. Za chwilę dołączę cennik, w którym plecak nie ma ceny. COALESCE(price, 0) zrobiłby z niego darmowy plecak, a taka promocja szybko trafiłaby do raportu marży. Brak ceny zostawiam jako NULL albo opisuję wprost, na przykład osobną kolumną z informacją, że ceny brakuje.
Duplikaty klucza mnożą wiersze
Mnożenie było już widać przy kubku: trzy transakcje, trzy wiersze. Tego się spodziewam, bo jeden produkt ma wiele sprzedaży. Gorzej, gdy dołączam tabelę, w której oczekuję jednego wiersza na klucz, a jest ich więcej. Dokładam syntetyczny cennik z historią cen:
-- example: prices
CREATE TEMP TABLE price_list (
product_id INTEGER NOT NULL,
valid_from DATE NOT NULL,
price INTEGER NOT NULL
);
INSERT INTO price_list VALUES
(1, '2026-01-01', 25),
(1, '2026-10-01', 29),
(2, '2026-01-01', 12),
(3, '2026-01-01', 89);
Kubek ma dwie ceny: starą i obowiązującą od października. Jeśli dołączę cennik tylko po product_id, kubek pojawi się dwa razy. Dlatego przy każdym dołączaniu słownika porównuję liczbę wierszy przed i po:
-- example: row-check
SELECT
(SELECT COUNT(*) FROM products) AS rows_before,
(SELECT COUNT(*)
FROM products AS p
LEFT JOIN price_list AS pl ON pl.product_id = p.product_id) AS rows_after;
Wynik to 4 przed i 5 po. LEFT JOIN nie gubi produktów, więc każdy dodatkowy wiersz oznacza klucz, który pasuje więcej niż raz. Szukam go wprost:
-- example: duplicate-keys
SELECT product_id, COUNT(*) AS price_rows
FROM price_list
GROUP BY product_id
HAVING COUNT(*) > 1
ORDER BY product_id;
Zapytanie zwraca produkt 1 z dwoma wierszami. Wiem już, że przyczyną jest historia cen, a do raportu potrzebuję jednej ceny na produkt. Wybieram najnowszą za pomocą ROW_NUMBER:
-- example: latest-price
WITH ranked AS (
SELECT product_id, price,
ROW_NUMBER() OVER (
PARTITION BY product_id ORDER BY valid_from DESC
) AS rn
FROM price_list
)
SELECT p.product_id, p.product_name, r.price
FROM products AS p
LEFT JOIN ranked AS r
ON r.product_id = p.product_id
AND r.rn = 1
ORDER BY p.product_id;
Cztery wiersze: kubek z ceną 29, notes 12, lampa 89 i plecak z NULL. Warunek r.rn = 1 stoi w ON. W WHERE usunąłby plecak z tego samego powodu, z którego plecak zniknął przy filtrze października: w jego dopisanym wierszu r.rn ma wartość NULL.

Takich duplikatów nie naprawiam przez SELECT DISTINCT. Przy identycznych wierszach ukryłby objaw, a przy dwóch różnych cenach i tak zostawi dwa wiersze. Szerszy przykład, w którym zdublowane dopasowania podwajają przychód, pokazuję w artykule o rodzajach JOIN.
Anti-join, NOT EXISTS i pułapka NOT IN
LEFT JOIN dobrze nadaje się do szukania braków. Które produkty nie sprzedały się w październiku?
-- example: anti-join
SELECT p.product_id, p.product_name
FROM products AS p
LEFT JOIN sales AS s
ON s.product_id = p.product_id
AND s.sale_date >= '2026-10-01'
AND s.sale_date < '2026-11-01'
WHERE s.sale_id IS NULL
ORDER BY p.product_id;
Lampa i plecak. Warunek daty jest w ON, a w WHERE zostaje tylko s.sale_id IS NULL. Taki zapis nazywa się anti-join: zostają wiersze lewej tabeli, dla których nie znalazło się żadne dopasowanie. Bez warunku daty wynikiem byłby sam plecak, jedyny produkt bez jakiejkolwiek sprzedaży.
IS NULL sprawdzam na kolumnie, która w prawej tabeli nigdy nie jest pusta: na kluczu głównym albo na kolumnie użytej w złączeniu. Gdybym sprawdzał kolumnę opcjonalną, na przykład rabat, transakcja bez rabatu udawałaby brak sprzedaży. Więcej o samym operatorze piszę w haśle IS NULL.
Ten sam wynik daje NOT EXISTS:
-- example: not-exists
SELECT p.product_id, p.product_name
FROM products AS p
WHERE NOT EXISTS (
SELECT 1
FROM sales AS s
WHERE s.product_id = p.product_id
AND s.sale_date >= '2026-10-01'
AND s.sale_date < '2026-11-01'
)
ORDER BY p.product_id;
Ten zapis mówi wprost, o co pytam: czy istnieje pasująca sprzedaż. Nie dołącza kolumn prawej tabeli, więc wynik ma najwyżej jeden wiersz na produkt. Gdy potrzebuję samej listy braków, wybieram właśnie go, a LEFT JOIN zostawiam, gdy chcę pokazać także dane dopasowanych wierszy. Który zapis jest szybszy, pokazuje plan zapytania w konkretnej bazie, więc nie zakładam tego z góry.
Kusi jeszcze trzecia wersja, z NOT IN:
-- example: not-in
SELECT p.product_id, p.product_name
FROM products AS p
WHERE p.product_id NOT IN (
SELECT s.product_id
FROM sales AS s
WHERE s.sale_date >= '2026-10-01'
AND s.sale_date < '2026-11-01'
)
ORDER BY p.product_id;
Pusty wynik. Wśród październikowych sprzedaży jest transakcja 6 z pustym product_id. Dla lampy warunek brzmi więc: 3 jest różne od 1, od 2 i od nieznanej wartości. Ostatniego porównania nie da się rozstrzygnąć, cały warunek ma wartość NULL, a wiersz odpada. Plecak odpada z tego samego powodu, a kubek i notes słusznie, bo sprzedały się w październiku. Opisują to dokumentacja PostgreSQL i tabela wyników operatora NOT IN w SQLite. Jeśli już używam NOT IN, dopisuję w podzapytaniu AND s.product_id IS NOT NULL. Częściej po prostu wybieram NOT EXISTS.
Kiedy wybieram inny JOIN
LEFT JOIN wybieram, gdy jedna strona wyznacza populację: wszystkie produkty, wszyscy klienci, wszystkie dni miesiąca. Pozostałe złączenia odpowiadają na inne pytania:
- INNER JOIN, gdy brak dopasowania wyklucza wiersz z odpowiedzi, na przykład interesują mnie tylko produkty, które się sprzedały.
- RIGHT JOIN zachowuje prawą stronę. Każdy RIGHT JOIN da się zapisać jako LEFT JOIN z zamienioną kolejnością tabel i zwykle tak robię, bo łatwiej czytam zapytanie, w którym zachowywana tabela stoi na początku.
- FULL JOIN, gdy chcę zobaczyć braki po obu stronach naraz: produkty bez sprzedaży i sprzedaż bez produktu, jak transakcja 6.
EXISTSiNOT EXISTS, gdy chcę tylko sprawdzić, czy dopasowanie istnieje, bez dokładania kolumn.
SQLite obsługuje RIGHT i FULL OUTER JOIN dopiero od wersji 3.39.0 z 2022 roku. Na starszej wersji zostaje LEFT JOIN z zamienionymi tabelami.
Uważam też na łańcuchy złączeń. Jeśli po LEFT JOIN do sprzedaży dołączę kolejną tabelę przez INNER JOIN z warunkiem na kolumnie sprzedaży, na przykład sklep, w którym zawarto transakcję, produkty bez sprzedaży znów odpadną. Kolejne złączenia po stronie opcjonalnej też zapisuję jako LEFT JOIN albo łączę je najpierw w podzapytaniu.
Krótkie ćwiczenie
Uruchom przykładowe dane i dopisz sprzedaż plecaka z 20 października. Zanim uruchomisz zapytania, zapisz oczekiwany wynik: ile wierszy zwróci przykład z warunkiem w ON, ile z warunkiem w WHERE i które produkty zostaną w anti-joinie. Potem dopisz do cennika drugą cenę notesu i sprawdź, czy kontrola liczby wierszy to wychwyci. Na koniec usuń transakcję 6 i zobacz, co zmieni się w zapytaniu z NOT IN.
Najważniejsze w pigułce
Dlaczego LEFT JOIN zwraca mniej wierszy, niż ma lewa tabela?
Sam LEFT JOIN zachowuje każdy wiersz lewej tabeli. Wiersze usuwa zwykle późniejszy warunek w WHERE na kolumnie prawej tabeli albo kolejny INNER JOIN w łańcuchu. Filtr prawej tabeli przenoszę wtedy do ON.
Dlaczego po LEFT JOIN mam więcej wierszy niż w lewej tabeli?
Wiersz z lewej pojawia się raz dla każdego pasującego wiersza z prawej. Porównuję liczbę wierszy przed i po złączeniu, a duplikaty klucza znajduję przez GROUP BY z HAVING COUNT(*) > 1.
Dlaczego NOT IN zwraca pusty wynik, choć braki istnieją?
Jeśli podzapytanie zwraca choć jeden NULL, warunek NOT IN dla wartości spoza listy ma wartość NULL zamiast prawdy i wiersz odpada. Wykluczam NULL w podzapytaniu albo używam NOT EXISTS.
