Joins sind das Herzstück relationalen SQLs. In einem normalisierten Schema liegen verwandte Daten in unterschiedlichen Tabellen — Kunden in customers, Bestellungen in orders, Items in order_items. Mit Joins fügst du sie für eine einzige Query zusammen. Hier die vier Standard-Joins (INNER, LEFT, RIGHT, FULL OUTER) mit konkreten Beispielen und ihrem typischen Verhalten.

Beispiel-Schema

Für die folgenden Beispiele nutzen wir zwei Tabellen:

SQL customers und orders
CREATE TABLE customers (
    id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name text NOT NULL
);

CREATE TABLE orders (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id bigint REFERENCES customers(id),
    total       numeric(10, 2) NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now()
);

INSERT INTO customers (name) VALUES
    ('Alice'),  -- id 1
    ('Bob'),    -- id 2
    ('Carol');  -- id 3 — bestellt nichts

INSERT INTO orders (customer_id, total) VALUES
    (1, 99.95),    -- Alice
    (1, 49.95),    -- Alice
    (2, 19.95),    -- Bob
    (NULL, 9.95);  -- Bestellung ohne zugewiesenen Kunden

Wichtig für die Beispiele: Carol hat keine Bestellung, und es gibt eine Bestellung ohne Kunden (customer_id IS NULL). Diese Asymmetrie macht den Unterschied zwischen den Join-Typen sichtbar.

INNER JOIN — nur die Schnittmenge

Liefert nur Zeilen, für die in beiden Tabellen ein passender Datensatz existiert:

SQL
SELECT c.name, o.total
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id;

Output:

SQL
 name  | total
-------+-------
 Alice | 99.95
 Alice | 49.95
 Bob   | 19.95

Carol fehlt (keine Bestellung), und die Bestellung ohne customer_id fehlt (kein passender Kunde). Nur das, was auf beiden Seiten matcht.

INNER ist optional — JOIN allein bedeutet INNER JOIN. Aus Lesbarkeit und Klarheit empfiehlt sich aber, das Wort zu schreiben, gerade in größeren Queries mit gemischten Join-Typen.

LEFT OUTER JOIN — alle Zeilen der linken Tabelle

Liefert alle Zeilen aus der linken Tabelle (customers), plus die passenden aus der rechten (orders). Wo es keinen Treffer gibt, sind die Spalten der rechten Tabelle NULL:

SQL
SELECT c.name, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id;

Output:

SQL
 name  | total
-------+-------
 Alice | 99.95
 Alice | 49.95
 Bob   | 19.95
 Carol |  NULL

Carol ist jetzt drin — mit total = NULL, weil sie keine Bestellung hat. Die Bestellung ohne Kunden fehlt weiterhin (sie ist auf der rechten Seite ohne Match).

LEFT JOIN ist die Kurzform von LEFT OUTER JOIN — Postgres akzeptiert beides.

Wann nutzen? Wenn die linke Tabelle das „Hauptobjekt" ist (alle Kunden, auch ohne Bestellung). Klassische Anwendung: „Liste aller User, mit ihrer letzten Bestellung — falls vorhanden".

RIGHT OUTER JOIN — alle Zeilen der rechten Tabelle

Spiegelbild zu LEFT JOIN: alle Zeilen rechts, plus passende von links:

SQL
SELECT c.name, o.total
FROM customers c
RIGHT JOIN orders o ON o.customer_id = c.id;

Output:

SQL
 name  | total
-------+-------
 Alice | 99.95
 Alice | 49.95
 Bob   | 19.95
 NULL  |  9.95

Jetzt ist die kunden-lose Bestellung dabei (name = NULL), aber Carol fehlt.

In der Praxis sieht man RIGHT JOIN selten — die meisten Leute formulieren denselben Query als LEFT JOIN mit getauschten Tabellen. Lesbarkeit-mäßig die übliche Wahl.

FULL OUTER JOIN — alles aus beiden

Vereinigt LEFT und RIGHT: alle Zeilen aus beiden Tabellen, NULL wo kein Match:

SQL
SELECT c.name, o.total
FROM customers c
FULL OUTER JOIN orders o ON o.customer_id = c.id;

Output:

SQL
 name  | total
-------+-------
 Alice | 99.95
 Alice | 49.95
 Bob   | 19.95
 Carol |  NULL    -- Carol ohne Bestellung
 NULL  |  9.95    -- Bestellung ohne Kunde

Sehr nützlich für Datenqualitäts-Reports: „zeige mir alles, wo die Verknüpfung nicht sauber ist". Auch hier: OUTER ist optional, FULL JOIN reicht.

Visualisierung mit Venn-Diagrammen

Stell dir die zwei Tabellen als überlappende Kreise vor:

Join-TypWas kommt ins Resultat
INNER JOINnur die Schnittmenge (Mitte)
LEFT JOINlinker Kreis komplett (mit Mitte)
RIGHT JOINrechter Kreis komplett (mit Mitte)
FULL OUTER JOINbeide Kreise komplett

Die NULL-Werte entstehen genau dort, wo eine Seite ohne Match ist.

USING — kürzer, wenn die Spalten gleich heißen

Wenn die Join-Spalten in beiden Tabellen denselben Namen haben, kann man sich die ON ... = ...-Schreibweise sparen:

SQL ON-Klausel
SELECT *
FROM order_items oi
INNER JOIN products p ON p.id = oi.product_id;
SQL USING-Klausel — nur wenn beide Spalten 'product_id' heißen
SELECT *
FROM order_items oi
INNER JOIN products p USING (product_id);

Voraussetzung: beide Tabellen müssen eine Spalte product_id haben. Bei USING taucht die Spalte im Resultset nur einmal auf (statt einmal aus jeder Seite).

In gut benannten Schemas kommt das oft nicht vor — typische Konvention ist id in der einen Tabelle, <entity>_id in der anderen. Daher meist ON.

Mehrere Joins kombinieren

In realen Queries werden mehrere Tabellen zusammengeführt:

SQL Drei Tabellen verbinden
SELECT
    c.name             AS customer,
    o.id               AS order_id,
    o.total,
    p.name             AS product,
    oi.quantity
FROM customers c
INNER JOIN orders o      ON o.customer_id = c.id
INNER JOIN order_items oi ON oi.order_id = o.id
INNER JOIN products p     ON p.id = oi.product_id
WHERE o.created_at >= now() - interval '30 days'
ORDER BY o.created_at DESC, o.id, p.name;

Postgres liest das von oben nach unten: erst customers mit orders joinen (über customer_id), dann das Ergebnis mit order_items (über order_id), dann mit products (über product_id). Der Optimizer ordnet das intern oft anders an, das Resultat bleibt dasselbe.

Kombinierte Join-Typen

Du kannst INNER und LEFT in einer Query mischen:

SQL Kunden mit Bestellungen, optional mit Adresse
SELECT
    c.name,
    o.total,
    a.city
FROM customers c
INNER JOIN orders o    ON o.customer_id = c.id    -- nur Kunden mit Bestellung
LEFT  JOIN addresses a ON a.customer_id = c.id;   -- Adresse optional

Hier sind nur Kunden im Resultat, die mindestens eine Bestellung haben (INNER), aber sie kommen auch ohne Adresse durch (LEFT). Eine flexible Kombination — solange du nachvollziehst, welche Seite welcher Join-Bedingung dominiert.

Mehrfach-Treffer und Zeilen-Multiplikation

Wenn die rechte Seite mehrere passende Zeilen pro linke hat, multipliziert sich das Resultat. Das ist oft beabsichtigt, kann aber überraschen:

SQL
myapp=> SELECT c.name, o.id AS order_id, o.total
        FROM customers c
        INNER JOIN orders o ON o.customer_id = c.id
        WHERE c.name = 'Alice';

 name  | order_id | total
-------+----------+-------
 Alice |        1 | 99.95
 Alice |        2 | 49.95

Alice taucht zweimal auf, weil sie zwei Bestellungen hat. Wenn man pro Kunde eine Zeile will (z. B. mit der Summe der Bestellungen), nutzt man GROUP BY:

SQL
SELECT
    c.name,
    count(o.id)     AS order_count,
    sum(o.total)    AS total_revenue
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.name
ORDER BY c.name;

GROUP BY ist dann das richtige Werkzeug — siehe Kapitel Aggregation.

Interessantes

JOIN allein bedeutet INNER JOIN.

Postgres akzeptiert beides. Manche Codebasen schreiben konsequent INNER aus, andere lassen's weg. In großen Queries mit gemischten Join-Typen ist „INNER" lesbarer — das Auge sieht, dass es kein OUTER ist.

Kein OUTER-Keyword nötig.

LEFT JOIN, RIGHT JOIN, FULL JOIN funktionieren. Das OUTER ist optional — Postgres versteht es auch mit. SQL-Standard erlaubt beides.

Reihenfolge in FROM ändert das Resultat (manchmal).

Bei reinen INNER JOINs ist die Reihenfolge egal — Postgres' Planner ordnet sie sowieso optimal an. Bei OUTER JOINs ist die Reihenfolge bedeutend: A LEFT JOIN B ist nicht dasselbe wie B LEFT JOIN A. Vorsicht beim Refactoring.

NATURAL JOIN existiert, ist aber gefährlich.

Ohne ON oder USING joinst du über alle gleich-benannten Spalten. Bei Schemas mit created_at in jeder Tabelle wird das schnell falsch. SQL-Standard erlaubt's, gute Konvention vermeidet es. Lieber explizit mit ON oder USING.

Join über mehrere Spalten: AND in ON.

ON a.x = b.x AND a.y = b.y für zusammengesetzte Schlüssel. Das ist nicht beschränkt auf Equality — ON a.start <= b.point AND b.point <= a.end für Range-Joins funktioniert genauso (wird allerdings selten optimal indiziert).

Indexe auf Join-Spalten lohnen sich fast immer.

Foreign-Key-Spalten (customer_id in orders) bekommen nicht automatisch einen Index — nur die referenzierte Primary-Key-Seite. Bei Joins über die FK-Spalte wird's ohne expliziten Index langsam. Faustregel: für jede FK-Spalte ein Index, sobald die Tabelle in Joins genutzt wird.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Joins & Subqueries

Zur Übersicht