N · NE · E · SE · S · SW · W · NW
← Magazin 15. Juni 2026
SQL · Trade-off

SQL-Subqueries oder Joins — wann welcher Weg

Korrelierte Subquery, CTE oder klassischer Join: drei Wege zu derselben Auswertung, und drei sehr unterschiedliche Ausführungspläne.

„Schreib’s als Join um, dann wird’s schneller” — dieser Reflex stimmt oft, aber nicht immer. Moderne Optimierer (PostgreSQL ab 12, der SQL Server, auch MariaDB) lösen viele Subqueries intern in Joins auf. In der Praxis bleiben drei Konstellationen, in denen die Wahl wirklich einen Unterschied macht: korrelierte Subqueries in der SELECT-Liste, abgeleitete Tabellen als CTE und der klassische Inner Join.

Die Beispielaufgabe

Gegeben sei eine Tabelle bestellung (rund 1,2 Mio. Zeilen) und kunde (45.000 Zeilen). Gesucht: pro Kunde der letzte Bestellbetrag.

Variante A, korrelierte Subquery in der SELECT-Liste:

SELECT k.kunde_id,
       k.name,
       (SELECT b.betrag
        FROM bestellung b
        WHERE b.kunde_id = k.kunde_id
        ORDER BY b.bestelldatum DESC
        LIMIT 1) AS letzter_betrag
FROM kunde k;

Lesbar, aber die Subquery läuft pro Kundenzeile einmal. Bei 45.000 Kunden sind das 45.000 Index-Lookups auf bestellung(kunde_id, bestelldatum DESC). Mit passendem Index erträglich, ohne katastrophal.

Variante B, abgeleitete Tabelle als CTE:

WITH letzte AS (
  SELECT kunde_id,
         betrag,
         ROW_NUMBER() OVER (PARTITION BY kunde_id
                            ORDER BY bestelldatum DESC) AS rn
  FROM bestellung
)
SELECT k.kunde_id, k.name, l.betrag
FROM kunde k
LEFT JOIN letzte l ON l.kunde_id = k.kunde_id AND l.rn = 1;

Die CTE materialisiert (oder inlinet — in PostgreSQL seit Version 12 standardmäßig) und gewinnt, sobald die Subquery komplex wird. Der Window-Function-Trick mit ROW_NUMBER() = 1 ist hier der entscheidende Hebel.

Variante C, klassischer Join über eine Aggregat-Subquery:

SELECT k.kunde_id, k.name, b.betrag
FROM kunde k
JOIN bestellung b ON b.kunde_id = k.kunde_id
JOIN (
  SELECT kunde_id, MAX(bestelldatum) AS max_datum
  FROM bestellung
  GROUP BY kunde_id
) m ON m.kunde_id = b.kunde_id AND m.max_datum = b.bestelldatum;

Funktioniert, aber: kippt um, sobald ein Kunde zwei Bestellungen am selben Tag hat — dann erscheint er doppelt im Ergebnis. In der Praxis fast immer ein Bug, den man erst bemerkt, wenn der Vertrieb sich über Doppelzeilen beschwert.

Was EXPLAIN zeigt

Auf einer realistischen Testdatenbank (PostgreSQL 16, 1,2 Mio. Bestellungen, passender Index auf bestellung(kunde_id, bestelldatum DESC)):

  • Variante A: ~ 380 ms, Plan zeigt einen Index Scan pro Kundenzeile.
  • Variante B: ~ 210 ms, ein WindowAgg über einen sortierten Index Scan.
  • Variante C: ~ 290 ms, plus die genannte Doppelzeilen-Falle.

Der Sieger ist hier die CTE mit Window Function — nicht, weil sie eleganter aussieht, sondern weil der Plan einmal sortiert und dann linear durchläuft.

Wann der Optimierer beide Wege gleich behandelt

Bei unkorrelierten Subqueries in der WHERE-Klausel ist die Wahl meist egal:

SELECT * FROM bestellung
WHERE kunde_id IN (SELECT kunde_id FROM kunde WHERE land = 'DE');

Der Planner schreibt das in einen Semi-Join um, der Plan ist identisch zu einem JOIN ... DISTINCT. Hier auf Lesbarkeit optimieren, nicht auf vermeintliche Performance.

Bei NOT IN mit nullable Spalten ist die Regel anders: NOT IN mit einer NULL-haltigen Subquery liefert ein leeres Ergebnis, weil SQL-Logik dreiwertig ist. NOT EXISTS oder ein LEFT JOIN ... WHERE x IS NULL umgehen das. Das ist kein Performance-Thema, sondern ein Korrektheitsthema.

Faustregeln aus der Praxis

  • Eine korrelierte Subquery in SELECT akzeptieren, wenn die äußere Tabelle klein ist (unter ~10.000 Zeilen) und der Index passt. Lesbarkeit gewinnt.
  • Sobald „pro Gruppe der jüngste/höchste Wert” gefragt ist: Window Function in einer CTE. Das ist die robusteste Form und liefert keine Doppelzeilen bei Gleichständen, wenn man ROW_NUMBER statt RANK nimmt.
  • Klassische Aggregat-Subqueries (GROUP BY ... MAX()) für reine Zahlen, nicht für „die Zeile mit dem Maximum”.
  • EXPLAIN (ANALYZE, BUFFERS) ist Pflicht, bevor man eine Variante als „schneller” deklariert. Eine ungeprüfte Umschreibung verschiebt das Problem oft nur.

Wer drei Varianten schreiben kann, schreibt am Ende meist die einfachste — aber bewusst, nicht aus Verlegenheit.


Ressort: SQL