Wszystkie artykuły

KajoData / SQL

INNER JOIN w SQL: dopasowania, klucze i duplikaty

Przejdę przez mały wynik krok po kroku: brakujące działy, powtórzone klucze, relacje wiele do wielu i wybór między JOIN a EXISTS.

Kajo RudzińskiOpublikowano Aktualizacja 11 min czytania

Dwie linie elementów spotykają się w maszynie wybierającej pasujące części

INNER JOIN potrafi bardzo przekonująco pokazać niepełny raport. Zapytanie działa, kolumny mają ładne nazwy, a brakujące rekordy po prostu znikają. Jeśli nie ustalisz, kto powinien znaleźć się w wyniku, możesz nawet nie zauważyć problemu. Ja przed pierwszym połączeniem zapisuję dwa zdania: co oznacza wiersz każdego źródła i które dopasowania chcę otrzymać.

Pokażę to na pracownikach i działach. To dane syntetyczne, bez związku z konkretną firmą. Przykłady dotyczą PostgreSQL 18. Najpierw wyprowadzę mały wynik ręcznie, później dołożę duplikaty, brakujące klucze i drugie kryterium połączenia. Taka kolejność pozwala zobaczyć, dlaczego poprawna składnia nie wystarcza do poprawnej analizy.

INNER JOIN zwraca pasujące pary

INNER JOIN jest operacją złączenia. Dla każdego wiersza jednego źródła zwraca każdy wiersz drugiego źródła, z którym warunek ON daje prawdę. Wiersze bez pasującej pary nie trafiają do wyniku. W PostgreSQL samo JOIN bez dodatkowego określenia oznacza INNER JOIN. Definicję opisuje dokumentacja złączeń.

Warto zapamiętać słowo „pary”. Jeśli jeden pracownik pasuje do trzech rekordów działu, otrzymasz trzy wiersze. Jeśli nie pasuje do żadnego, otrzymasz zero. Operacja nie wybiera automatycznie pierwszego pasującego rekordu i nie sprawdza za Ciebie, czy dział powinien być unikalny.

Nie traktuję też INNER JOIN jako wspólnej części dwóch list nazw. Łączymy wiersze według warunku, a potem wybieramy kolumny do prezentacji. Dwa różne dopasowania mogą wyglądać identycznie po ukryciu identyfikatorów. To nadal dwa dopasowania, dopóki osobna operacja ich nie połączy.

Mały przykład: pracownik i nazwa działu

Źródło employees ma cztery wiersze. Każdy oznacza jednego pracownika. department_id wskazuje jego dział, a NULL oznacza brak znanego przypisania.

employee_id employee_name department_id
1 Ada 10
2 Ben 20
3 Celina 30
4 Dan NULL

Źródło departments ma trzy działy. Identyfikatory są w tym przykładzie unikalne. Zwróć uwagę, że nie ma działu 30, ale jest dział 40 bez przypisanego pracownika z naszej listy.

department_id department_name
10 Analysis
20 Operations
40 Sales

Chcę pokazać wyłącznie pracowników z dopasowaną nazwą działu. To ograniczenie jest częścią pytania. Jeżeli odbiorca potrzebuje wszystkich pracowników, również z niepełnym przypisaniem, wybiorę inne połączenie. Nie pozwolę, żeby brakujące powiązanie po cichu decydowało o zakresie raportu.

Zapytanie, które możesz przeczytać w całości

WITH employees(employee_id, employee_name, department_id) AS (
    VALUES
        (1, 'Ada', 10),
        (2, 'Ben', 20),
        (3, 'Celina', 30),
        (4, 'Dan', NULL)
), departments(department_id, department_name) AS (
    VALUES
        (10, 'Analysis'),
        (20, 'Operations'),
        (40, 'Sales')
)
SELECT e.employee_id, e.employee_name, d.department_name
FROM employees AS e
INNER JOIN departments AS d
    ON d.department_id = e.department_id
ORDER BY e.employee_id;

WITH nadaje nazwy małym źródłom zbudowanym przez VALUES. Dokumentacja VALUES potwierdza możliwość podania w ten sposób tabeli wartości bez tworzenia trwałej tabeli. W kolejnych przykładach przyjmuję te same źródła; zachowaj wcześniejsze WITH i podmień końcowy SELECT.

Aliasy e i d określają stronę, z której pochodzi pole. Piszę e.department_id, bo obie tabele mają identyfikator działu. Jawne prefiksy są szczególnie pomocne wtedy, gdy zapytanie rośnie i ktoś dodaje kolejną kolumnę o znajomej nazwie.

Dlaczego wynik ma tylko dwa wiersze

employee_id employee_name department_name
1 Ada Analysis
2 Ben Operations

Ada pasuje do działu 10, a Ben do działu 20. Dla Celiny nie ma działu 30, więc jej wiersz nie trafia do wyniku. Dan ma NULL w kluczu. Zwykła równość z takim brakiem nie daje prawdy, więc on również nie ma dopasowania. Dział Sales nie jest pokazany, bo żaden pracownik do niego nie pasuje.

W rezultacie nie mogę nazwać dwóch wierszy „wszystkimi pracownikami firmy”. To pracownicy z dopasowaniem w podanym słowniku działów. Ta różnica może wpłynąć na mianownik wskaźnika, podział kosztów i interpretację braków. Czysty wygląd tabeli łatwo usypia czujność.

Gdybym zamienił kolejność obu źródeł, zestaw pasujących par przy tej równości pozostałby taki sam. Nie oznacza to zachowania układu wszystkich kolumn przy SELECT gwiazdka ani obietnicy identycznego planu wykonania. Używam jawnej listy kolumn oraz ORDER BY, żeby określić prezentację wyniku.

Brak dopasowania może ujawniać problem jakości

Celina i Dan zniknęli z różnych powodów. U Celiny jest konkretny numer działu, którego nie ma w słowniku. U Dana nie znam numeru. W obu przypadkach INNER JOIN usuwa wiersz, ale działania naprawcze mogą być inne. Warto rozdzielić te sytuacje w kontroli danych.

Brak działu w słowniku nie musi od razu oznaczać literówki. Słownik może pochodzić z innego dnia, obejmować tylko aktywne jednostki albo być niepełnym eksportem. Najpierw sprawdzam zakres i pochodzenie obu źródeł. Dopiero potem oceniam rekord jako błędny.

Do raportu kontroli użyłbym LEFT JOIN, zachowując pracowników i pokazując brak prawego identyfikatora. Jeżeli potrzebuję tylko listy osób bez powiązania, rozważę NOT EXISTS. INNER JOIN nadaje się do pokazania dopasowań, ale sam nie pokaże tego, co odrzucił.

Dwa rekordy słownika mnożą wynik

Rozważ osobny wariant danych. W tabeli działów identyfikator 10 występuje dwukrotnie: raz z nazwą Analysis, drugi raz z nazwą Analytics. Pozostałe rekordy pozostają takie same. Ada dopasuje się do obu tych wierszy. Ben nadal pasuje raz. Wynik urośnie z dwóch do trzech wierszy.

Nie jest to automatycznie błąd silnika ani dowód, że INNER JOIN działa losowo. Warunek dostał dwa poprawne według składni dopasowania. Pytanie brzmi, czy oba rekordy słownika powinny istnieć. Może to duplikat importu, a może historia zmian, w której zabrakło warunku daty.

W bazie PRIMARY KEY może wymuszać unikalność i brak NULL. FOREIGN KEY może wymuszać istnienie wskazanego rekordu; jego kolumna nadal może dopuszczać NULL, jeśli nie dodano NOT NULL. Te role rozróżnia dokumentacja ograniczeń. JOIN sam nie tworzy takich gwarancji.

Przed połączeniem mogę sprawdzić kandydat na klucz:

SELECT department_id, COUNT(*) AS row_count
FROM departments
GROUP BY department_id
HAVING COUNT(*) > 1;

Dla pierwotnego słownika wynik będzie pusty. Dla opisanego wariantu pokaże dział 10 i liczbę dwa. To wskazówka do analizy źródła, a nie pozwolenie na arbitralne usunięcie jednego wiersza. Regułę wyboru wersji trzeba uzasadnić znaczeniem danych.

Dwa prostokątne elementy i trzy kwadratowe tworzą sześć osobnych par

Relacja wiele do wielu wymaga świadomej interpretacji

Załóżmy teraz dwa osobne źródła przypisań. Pierwsze ma dwa rekordy dla projektu A, drugie trzy rekordy dla tego samego projektu. Połączenie wyłącznie przez projekt da sześć par. Każdy z dwóch rekordów pierwszej strony pasuje do każdego z trzech rekordów drugiej.

Może to być oczekiwane, jeśli naprawdę tworzysz listę wszystkich kombinacji. Jeśli jednak chcesz dopisać do projektu sumę godzin i budżet, sześć wierszy może zwielokrotnić oba pomiary. Zanim policzysz SUM, określ poziom, na którym każdy pomiar jest zapisany.

Ja sprowadzam niezależne zbiory do wspólnego poziomu przed łączeniem, kiedy taki poziom odpowiada pytaniu. Dla raportu projektowego może to oznaczać jedną sumę godzin i jedną kwotę budżetu na projekt. Szczegółowe zadania i szczegółowe faktury mogą wymagać dwóch osobnych widoków zamiast jednego szerokiego wyniku.

DISTINCT nie zastępuje tej decyzji. Usuwa identyczne wybrane wiersze, ale może zarówno ukryć błąd, jak i skasować prawidłowe zdarzenia o tych samych widocznych wartościach. Najpierw zachowuję identyfikatory obu stron i wyjaśniam pochodzenie każdej pary.

Czasami klucz składa się z dwóch pól

Numer zamówienia może być unikalny tylko wewnątrz jednej firmy albo systemu. Jeżeli połączę dwa źródła wyłącznie po order_id, numer 7 z firmy A może dopasować się do numeru 7 z firmy B. SQL nie zna ukrytej w głowie autora definicji numeracji.

W takim modelu potrzebuję obu części klucza. Poniższy fragment pokazuje warunek dla źródeł zamówień i pozycji, które mają kolumny company_id oraz order_id.

SELECT o.company_id, o.order_id, i.item_id
FROM orders AS o
INNER JOIN order_items AS i
    ON i.company_id = o.company_id
    AND i.order_id = o.order_id;

To osobny scenariusz, niezależny od tabel pracowników. Jedna pozycja musi zgadzać się z zamówieniem zarówno co do firmy, jak i numeru. W rzeczywistej bazie warto sprawdzić odpowiednie ograniczenia złożone, zamiast zakładać, że sama dokumentacja biznesowa uniemożliwia błędny zapis.

Podobnie działa łączenie danych po okresie lub wersji. Jeśli klucz biznesowy powtarza się w historii, sama zgodność identyfikatora może być niewystarczająca. Zapisz, którą wersję źródła chcesz zobaczyć przy danym zdarzeniu. Nazwa tabeli „aktualna” nie zastępuje reguły.

ON, USING i ryzyko NATURAL JOIN

Gdy obie strony mają tę samą nazwę klucza, mogę napisać USING (department_id). W naszym przykładzie oznacza to porównanie identyfikatorów działu. USING pokazuje też jedną wspólną kolumnę klucza przy wyborze wszystkich kolumn, podczas gdy ON pozostawia obie.

NATURAL JOIN sam wybiera wszystkie nazwy kolumn wspólne dla źródeł. To wygodny skrót, który może zmienić znaczenie po dodaniu nowej kolumny o tej samej nazwie. Dokumentacja PostgreSQL wprost opisuje ten mechanizm i ryzyko zmiany schematu.

W raporcie, który ktoś ma utrzymywać, wolę jawny ON albo świadomie wybraną listę USING. Widzę wtedy umowę między źródłami. Dodatkowa kolumna status w obu tabelach nie powinna przypadkiem stać się kolejnym kryterium dopasowania.

Filtry, których miejsce warto rozumieć

Przy zwykłym INNER JOIN warunek dotyczący wyniku może znaleźć się w ON albo WHERE bez zmiany zbioru pasujących wierszy, jeśli zachowasz tę samą logikę. Ja zwykle zapisuję relację kluczy w ON, a ograniczenie raportu w WHERE. Dzięki temu łatwiej przeczytać intencję.

Jeżeli pokażę tylko dział 10, Ada zostanie, a Ben zniknie. To świadomy filtr populacji już dopasowanej. Warto nazwać go w opisie raportu, bo później ktoś może błędnie uznać wynik za pełną listę wszystkich poprawnie przypisanych pracowników.

Tego nawyku nie przenoś mechanicznie na LEFT JOIN. Tam przesunięcie warunku z ON do WHERE może usunąć wiersze zachowane przez złączenie zewnętrzne. Zmieniając rodzaj JOIN podczas poprawki, przejrzyj ponownie wszystkie filtry prawej strony. Sama podmiana słowa nie kończy pracy.

Gdy interesuje Cię tylko istnienie dopasowania

SELECT e.employee_id, e.employee_name
FROM employees AS e
WHERE EXISTS (
    SELECT 1
    FROM departments AS d
    WHERE d.department_id = e.department_id
)
ORDER BY e.employee_id;

Dla pierwotnych danych dostanę Adę i Bena. W wariancie z dwoma rekordami działu 10 Ada nadal pojawi się raz, ponieważ EXISTS sprawdza istnienie choć jednego dopasowania. Nie dołączam kolumn działu. Dokumentacja EXISTS rozróżnia ten efekt od tworzenia wszystkich par.

Nie obiecuję, że EXISTS zawsze będzie szybszy. Wybieram go dlatego, że dobrze opisuje pytanie. Wydajność zależy od planu i danych. Jeśli potrzebujesz nazw działów z każdego dopasowania, INNER JOIN nadal ma właściwą rolę. Więcej wariantów pokazuję w poradniku o podzapytaniach.

Warunek JOIN może opisywać przedział

Równość identyfikatorów jest częsta, ale ON może zawierać inne warunki logiczne. Rozważ osobny przykład przypisania wartości do przedziału. W tabeli measurements masz identyfikator pomiaru i pole value. Tabela bands zawiera dolną granicę lower_bound, górną upper_bound oraz nazwę przedziału band_name.

SELECT m.measurement_id, m.value, b.band_name
FROM measurements AS m
INNER JOIN bands AS b
    ON m.value >= b.lower_bound
    AND m.value < b.upper_bound;

Załóżmy dwa przedziały syntetyczne: pierwszy od zera włącznie do stu wyłącznie, drugi od stu włącznie do dwustu wyłącznie. Pomiar o wartości sto pasuje tylko do drugiego. Dolna granica jest włączona, a górna wyłączona. Dzięki temu wspólna granica nie tworzy dwóch par.

Gdybym użył BETWEEN dla obu przedziałów, sto należałoby do obu, ponieważ BETWEEN obejmuje oba końce. Ten szczegół opisuje dokumentacja porównań. Zapis przedziału jest więc częścią definicji grupy, a nie kosmetyką składni.

Przy takich połączeniach osobno sprawdzam kompletność i rozłączność przedziałów. Luka może sprawić, że pomiar nie trafi nigdzie. Nakładające się zakresy mogą nadać mu dwie kategorie. INNER JOIN wykona oba zachowania zgodnie z warunkiem, nawet gdy odbiorca spodziewa się dokładnie jednej kategorii na pomiar.

Podobnie można modelować okres obowiązywania wersji rekordu. Jeżeli koniec jednej wersji jest początkiem kolejnej, trzeba jasno ustalić, która obejmuje zdarzenie na granicy. Samo dodanie daty do warunku nie rozwiązuje problemu niejednoznacznych okresów źródłowych. Przed analizą sprawdzam regułę ich budowania.

Puste źródło i brakujący klucz

Gdy którakolwiek strona zwykłego INNER JOIN jest pusta, nie powstaje żadna para. To przydatny przypadek do przewidywania wyniku raportu dla nowego okresu, w którym nie ma jeszcze zdarzeń. Brak wyniku może być poprawny, ale nie powinien być mylony z miarą równą zero.

Jeżeli obie strony mają NULL w kluczu, zwykła równość również nie tworzy pary. Dwa nieznane przypisania nie dowodzą tej samej tożsamości. Dlatego nie zastępuję wszystkich braków jednym wspólnym numerem przed łączeniem. Mógłbym wtedy połączyć obiekty, o których wiem tylko tyle, że brakuje ich identyfikatorów.

Jak sprawdzam gotowe połączenie

Najpierw przewiduję wynik dla kilku rekordów: jednego dopasowanego, jednego bez dopasowania oraz jednego z wieloma dopasowaniami. Potem porównuję liczbę różnych kluczy wejścia i wyjścia. Spadek liczby pracowników może być zamierzony, ale powinien mieć wyjaśnienie.

Następnie patrzę na miary. Jeśli suma kwot rośnie po dołączeniu opisowego słownika, sprawdzam jego unikalność. Jeżeli spada, szukam odrzuconych rekordów. Kontrola samej liczby wierszy nie wykryje sytuacji, w której część zdarzeń znika, a inne są jednocześnie powielane.

Na końcu oddzielam poprawność od wydajności. Moje ręczne śledzenie dopasowań wyjaśnia wynik, ale nie określa fizycznego algorytmu bazy. Do planu służy EXPLAIN. Nie wnioskuję o szybkości wyłącznie z kolejności tabel zapisanej w zapytaniu.

Ćwiczenie: przewiduj wynik przed odczytem

Dopisz do pierwotnego słownika dział 30 o nazwie Support. Wynik INNER JOIN powinien mieć teraz trzy osoby: Adę, Bena i Celinę. Dan nadal nie pasuje, bo jego identyfikator działu jest nieznany. Dział 40 nadal nie tworzy żadnej pary.

Następnie dodaj drugi rekord działu 10. Przewidź cztery wiersze wyniku: dwa dla Ady, jeden dla Bena i jeden dla Celiny. Porównaj to z wersją EXISTS, która nadal pokaże trzy osoby. Odpowiedz, która wersja pasuje do pytania „kto ma dział obecny w słowniku?”.

Jeśli umiesz uzasadnić obecność i brak każdej osoby, masz dobrą podstawę do dalszej nauki. Kolejny krok to zachowanie brakujących dopasowań przez LEFT JOIN i świadome logiczną kolejność klauzul. Przy każdym nowym połączeniu wracaj do pytania, co przedstawia jeden wiersz.

Film o łączeniu tabel

Zachowuję polski materiał KajoData opisany jako przykłady z MySQL. Ten artykuł wyjaśnia temat w PostgreSQL 18, z własnym małym zbiorem i jawnymi założeniami.

Najważniejsze w pigułce

Czy INNER JOIN pokazuje rekordy bez dopasowania?

Nie. Zwraca wyłącznie pary wierszy, dla których warunek ON daje prawdę. Do zachowania wszystkich rekordów jednej strony służy złączenie zewnętrzne.

Dlaczego INNER JOIN zwiększył liczbę wierszy?

Jeden rekord może pasować do wielu rekordów drugiej strony. Sprawdź unikalność klucza i to, czy warunek obejmuje wszystkie jego części.

Kiedy wybrać EXISTS zamiast INNER JOIN?

Gdy potrzebujesz sprawdzić istnienie dopasowania i nie dołączasz jego kolumn. EXISTS nie tworzy osobnego wiersza dla każdego pasującego rekordu.

Przy pracy nad artykułem użyto w zautomatyzowany sposób sztucznej inteligencji. Jeśli też chcesz tak umieć, sprawdź ten projekt.

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.