„Welche Kunden haben eine Bestellung?" — drei gangbare Wege, dieselbe Antwort zu bekommen: EXISTS, IN-Subquery oder JOIN mit DISTINCT. Funktional liefern sie meist dieselben Resultate; in Performance und NULL-Sicherheit unterscheiden sie sich. Hier die Faustregeln und der berüchtigte NOT IN-mit-NULL-Bug.

Drei Varianten dasselbe zu sagen

Beispiel-Frage: „Welche Kunden haben mindestens eine bezahlte Bestellung?"

SQL Variante 1: EXISTS
SELECT c.id, c.name
FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o
    WHERE o.customer_id = c.id
      AND o.status = 'paid'
);
SQL Variante 2: IN mit Subquery
SELECT c.id, c.name
FROM customers c
WHERE c.id IN (
    SELECT customer_id FROM orders WHERE status = 'paid'
);
SQL Variante 3: JOIN mit DISTINCT
SELECT DISTINCT c.id, c.name
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid';

Alle drei liefern dieselbe Liste — die Kunden mit einer bezahlten Bestellung.

Performance — und warum es heute kaum noch Unterschied macht

Vor 10 Jahren gab's klare Performance-Unterschiede zwischen den drei Patterns. Moderne PostgreSQL-Optimizer (PG 12+) erkennen die Äquivalenz und transformieren intern oft alle drei in dieselbe Plan-Form (typisch Hash Semi Join).

In aktuellen Postgres-Versionen sind die Unterschiede meist:

PatternTypischer PlanGeschwindigkeit
EXISTSHash Semi Joinmeist optimal
IN (subquery)Hash Semi Joinpraktisch identisch zu EXISTS
JOIN + DISTINCTHash Join + Sort/Aggregatmeist langsamer wegen DISTINCT-Overhead

Wer's wirklich genau wissen will: EXPLAIN ANALYZE auf der eigenen Daten. Bei kleinen Tabellen ist's egal, bei großen kann der DISTINCT-Overhead spürbar werden — EXISTS und IN sind dann meist schneller.

Der NOT IN-Killer — NULL macht alles kaputt

Die Inverse-Frage: „Welche Kunden haben keine bezahlte Bestellung?" Hier wird's gefährlich.

SQL Vorsicht: NOT IN bei NULL-haltiger Subquery
SELECT c.id, c.name
FROM customers c
WHERE c.id NOT IN (
    SELECT customer_id FROM orders WHERE status = 'paid'
);

Das funktioniert — solange customer_id in orders keine NULL-Werte enthält. Sobald eine einzige Zeile mit customer_id IS NULL dabei ist, liefert die Query null Zeilen zurück. Egal wie viele Kunden eigentlich passen würden.

Warum? SQL-Logik: c.id NOT IN (1, 2, NULL) ist äquivalent zu c.id <> 1 AND c.id <> 2 AND c.id <> NULL. Und c.id <> NULL ergibt NULL (nicht TRUE). Mit AND-Verknüpfung wird die ganze Bedingung NULL, also „nicht erfüllt".

SQL Saubere Variante: NOT EXISTS
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (
    SELECT 1 FROM orders o
    WHERE o.customer_id = c.id
      AND o.status = 'paid'
);

NOT EXISTS ist NULL-sicher — es prüft nur die Existenz, nicht Gleichheit-mit-NULL. Empfehlung: bei „nicht in"-Logik immer NOT EXISTS nehmen, nicht NOT IN.

Lesbarkeit — die andere wichtige Dimension

Performance ist oft ähnlich; Lesbarkeit unterscheidet die Varianten deutlich:

Use-CaseBestes Pattern (lesbar)
„Filter Kunden, die …"EXISTS oder IN — Absicht klar
„Zeige Kunden + ihre Bestellungen"JOIN — Spalten beider Tabellen sichtbar
„Filter mit fester Liste" (status IN ('paid','shipped'))direktes IN — keine Subquery nötig
„Kunden, die KEIN X haben"NOT EXISTS (NULL-sicher)
„Berechnete Zwischenwerte pro Zeile"LATERAL JOIN

Faustregel: Wenn du nur filtern willst → EXISTS/IN. Wenn du Spalten der zweiten Tabelle anzeigen willst → JOIN.

Wenn Spalten der inneren Tabelle ins Resultat sollen

Manchmal ist die Frage doppelt: „Kunden mit bezahlten Bestellungen — und wieviele/wieviel Geld":

SQL EXISTS reicht nicht — JOIN nötig
SELECT
    c.id,
    c.name,
    count(o.id)         AS paid_orders,
    sum(o.total)        AS revenue
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id AND o.status = 'paid'
GROUP BY c.id, c.name
ORDER BY revenue DESC;

EXISTS würde dir nur „ja/nein" liefern — nicht den Count oder die Summe. Hier ist JOIN mit GROUP BY der natürliche Weg.

Praxis-Cheat-Sheet

FrageEmpfehlung
„Hat Kunde X mindestens eine Bestellung?"EXISTS
„Welche Kunden haben Bestellungen?"EXISTS oder IN
„Welche Kunden haben KEINE Bestellungen?"NOT EXISTS (nicht NOT IN!)
„Bestellungen mit Kunden-Daten dazu"JOIN
„Pro Kunde Anzahl/Summe der Bestellungen"LEFT JOIN + GROUP BY
„Kunden mit id IN (1, 2, 3)"direktes IN mit Liste
„Kunden, deren letzte Bestellung > 100 €"LATERAL mit LIMIT 1 oder Window

Index-Hinweise

Damit alle drei Varianten schnell sind, brauchen sie meist denselben Index — auf der referenzierenden Spalte:

SQL
CREATE INDEX orders_customer_idx ON orders (customer_id);

Foreign-Key-Spalten bekommen nicht automatisch einen Index — Postgres erzeugt nur einen für die Primary-Key-Seite. Wer regelmäßig WHERE customer_id = … oder Joins über customer_id macht, sollte den Index manuell anlegen.

Bei zusätzlichen Filtern (WHERE status = 'paid') lohnt sich oft ein Composite-Index oder ein Partial-Index:

SQL Partial-Index für häufige Status-Filter
CREATE INDEX orders_paid_customer_idx
ON orders (customer_id)
WHERE status = 'paid';

Der Index ist viel kleiner und greift bei Queries, die genau diese Bedingung nutzen.

Häufige Stolperfallen

NOT IN + NULL = stiller Bug.

Klassisch: WHERE id NOT IN (SELECT customer_id FROM orders) liefert leeres Resultat, sobald eine einzige Order mit customer_id IS NULL existiert. Postgres-Verhalten ist standardkonform, aber Bug-Auslöser. Daher: immer NOT EXISTS für „nicht enthalten"-Logik. Faustregel: NOT IN nur mit konstanten Listen ohne NULL.

JOIN + DISTINCT ist meistens schlechter.

Wenn Filter das Hauptziel ist (kein Anzeigen der zweiten Tabelle), führt JOIN ... GROUP BY oder JOIN ... DISTINCT zu mehr Arbeit als nötig. EXISTS ist konzeptuell und oft auch performance-mäßig besser. DISTINCT/GROUP BY nur, wenn die zweite Tabelle wirklich auswertbar gebraucht wird.

EXISTS hat keinen DISTINCT-Effekt — und braucht keinen.

Bei EXISTS gibt's pro Outer-Zeile maximal eine Resultat-Zeile (ja oder nein). Im Gegensatz zu JOIN, der bei mehreren Treffern Duplikate produziert, brauchst du bei EXISTS nie DISTINCT. Sehr lesbar und performant.

Korrelierte Subqueries werden vom Optimizer transformiert.

Die mentale Vorstellung „pro Outer-Zeile läuft die Inner-Subquery" ist oft falsch — Postgres' Planner formt das in Hash- oder Merge-Joins um. EXPLAIN ANALYZE zeigt den realen Plan. Bei großen Datenmengen also nicht aus Angst vor „N+1" auf JOINs ausweichen — meist macht's der Optimizer eh richtig.

Lesbarkeit schlägt Mikro-Optimierung.

Bei ähnlicher Performance ist das Pattern besser, das die Absicht klarer macht. „Kunden, die …" → EXISTS/IN. „Kunden mit …" (zusätzliche Daten) → JOIN. Wer eine besonders schnelle Variante braucht, misst — vor allem an realen Daten, nicht synthetischen Tests.

Composite- und Partial-Indexe für die häufigen Filter.

Eine customer_id-Spalte ohne Index in einer großen orders-Tabelle macht alle drei Varianten langsam — nicht das Pattern ist schuld, sondern der fehlende Index. Vor dem Vergleich von Varianten erst die Index-Hygiene prüfen.

ORM-generierte Queries oft JOIN-lastig.

ORMs wie Hibernate, ActiveRecord oder Django-ORM bevorzugen Joins. Wer in einer ORM-Codebase Performance verbessert, sollte prüfen, ob ein direkter EXISTS-Subquery einfacher und schneller wäre — manche ORMs erzeugen bei „relation exists?"-Checks unnötige Joins mit DISTINCT.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Joins & Subqueries

Zur Übersicht