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.

Kajo RudzińskiOpublikowano Aktualizacja 8 min czytania

Dwie pikselowe platformy: z każdej skrzynki po lewej biegnie most na prawą stronę, a jeden most kończy się pustą ramą zamiast skrzynki.

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 z ON.
  • 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.

Pikselowa taśma: cztery skrzynki przejeżdżają przez bramę dopasowania, a za nią jedna z nich jedzie dalej w dwóch identycznych kopiach.

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.
  • EXISTS i NOT 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.

Kajo Rudziński

Newsletter KajoData

Zostańmy w kontakcie.

Piszę o analizie danych, uczeniu się i zmianie pracy. Dzielę się tym, co pomaga mi ogarniać technologię i zawodowe decyzje. Jeśli lubisz ten sposób myślenia, zapraszam do mojego newslettera.