Eine Subquery (auch Sub-SELECT) ist ein in eine andere Query eingebetteter SELECT. Sie kann als Filter in WHERE, als Spalte in SELECT, als Tabelle in FROM oder als Zeile in INSERT auftreten. Hier konzentrieren wir uns auf den häufigsten Fall: Subqueries als Filter in WHERE.
Drei Formen von Subqueries in WHERE
| Form | Liefert | Verwendung |
|---|---|---|
| Skalar-Subquery | genau einen Wert | direkter Vergleich: WHERE total > (SELECT avg(total) FROM orders) |
Listen-Subquery (IN) | eine Liste von Werten | WHERE id IN (SELECT customer_id FROM orders) |
EXISTS-Subquery | TRUE/FALSE | WHERE EXISTS (SELECT 1 FROM orders WHERE customer_id = c.id) |
Alle drei sind nützlich — und in vielen Fällen austauschbar. Der Unterschied liegt oft in Lesbarkeit und Performance.
Skalar-Subqueries — ein einzelner Wert
Eine Subquery, die genau einen Wert liefert, kann wie ein Konstanten-Vergleich verwendet werden:
SELECT id, total
FROM orders
WHERE total > (SELECT avg(total) FROM orders)
ORDER BY total DESC;Postgres berechnet avg(total) einmal (es ist konstant für die ganze Query) und vergleicht jede Zeile damit.
Wichtig: die Subquery muss garantiert genau einen Wert liefern. Wenn sie mehr Zeilen liefert, gibt's einen Laufzeit-Fehler:
myapp=> SELECT id FROM orders
WHERE total > (SELECT total FROM orders LIMIT 10);
ERROR: more than one row returned by a subquery used as an expressionLösung: Aggregat (max(total), avg(total)) oder LIMIT 1 plus klares Sortier-Kriterium.
IN mit Subquery — Liste von Werten
Wenn man eine Liste von IDs/Werten dynamisch ermitteln will, ist IN mit Subquery der Klassiker:
SELECT id, name
FROM customers
WHERE id IN (SELECT customer_id FROM orders WHERE status = 'paid');Liest sich wie der Code-Pseudocode: „nimm Kunden, deren id in der Liste der Customer-IDs aus paid-Orders steht".
NOT IN als Inverse:
-- Kunden ohne bezahlte Bestellung
SELECT id, name
FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders WHERE status = 'paid');Falle: wenn die Subquery NULL-Werte liefert (z. B. weil eine Bestellung customer_id = NULL hat), schlägt NOT IN für alle Zeilen fehl — ein klassischer SQL-Bug. Detail im Artikel EXISTS vs. IN vs. JOIN.
EXISTS — gibt's mindestens eine Zeile?
EXISTS (subquery) ist TRUE, sobald die Subquery mindestens eine Zeile liefert. Inhalt der Subquery ist dabei egal — Konvention ist SELECT 1 FROM …:
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'
);Diese Form ist korreliert — die innere Subquery referenziert c.id aus der äußeren Query. Das ist erlaubt und der häufige Use-Case.
Postgres optimiert EXISTS clever: sobald eine passende Zeile gefunden ist, hört die Subquery auf zu suchen. Bei großen Datenmengen oft schneller als IN-Subquery.
NOT EXISTS als Inverse — und im Gegensatz zu NOT IN NULL-sicher:
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id
);Korreliert vs. unkorreliert
Eine Subquery ist korreliert, wenn sie auf Spalten der äußeren Query zugreift. Sonst unkorreliert.
SELECT * FROM products
WHERE price > (SELECT avg(price) FROM products);
-- ^
-- Subquery referenziert NICHT die äußere Tabelle.
-- Wird einmal berechnet, der Wert wird gegen jede Zeile getestet.SELECT c.name,
(SELECT count(*) FROM orders o WHERE o.customer_id = c.id) AS orders_count
-- ^
-- Subquery referenziert c.id — wird pro Kunden-Zeile ausgewertet
FROM customers c;Performance-Implikation: unkorrelierte Subqueries sind meist günstig (laufen einmal). Korrelierte können teuer sein — sie laufen einmal pro äußerer Zeile, wenn der Optimizer das nicht zu einem Hash-Join transformieren kann.
Bei EXISTS ist die Korrelation idiomatisch; Postgres optimiert das gut. Bei skalaren korrelierten Subqueries in SELECT (Beispiel oben) lieber prüfen, ob ein LEFT JOIN mit GROUP BY schneller wäre.
ANY und ALL
Zwei weniger bekannte Operatoren für Listen-Subqueries:
| Schreibweise | Bedeutung |
|---|---|
x = ANY (subquery) | identisch zu x IN (subquery) |
x = ALL (subquery) | gleicht JEDEM Wert in der Subquery |
x > ANY (subquery) | größer als MINDESTENS einer |
x > ALL (subquery) | größer als ALLE |
-- Bestellungen, die größer sind als alle bezahlten Bestellungen
SELECT id, total FROM orders
WHERE total > ALL (
SELECT total FROM orders WHERE status = 'paid'
);-- Bestellungen, die größer sind als mindestens eine paid-Bestellung
SELECT id, total FROM orders
WHERE total > ANY (
SELECT total FROM orders WHERE status = 'paid'
);In der Praxis selten — meist werden dieselben Selektionen mit MAX(...)/MIN(...)-Subqueries und einfachem Vergleich gemacht (lesbarer).
Subqueries vs. Joins — wann was?
Viele Subquery-Filter lassen sich auch als Joins formulieren:
SELECT id, name FROM customers
WHERE id IN (SELECT customer_id FROM orders WHERE status = 'paid');SELECT DISTINCT c.id, c.name
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid';Beide Resultate identisch. Welche Variante besser ist, hängt vom Kontext ab:
- Subquery ist meist lesbarer für „Filter": die Absicht „nimm nur Kunden, die …" ist klar.
- Join ist nötig, sobald du Spalten aus der zweiten Tabelle anzeigen willst (mehr als nur als Filter).
- EXISTS ist NULL-sicher und oft am performantesten.
- DISTINCT im Join ist nötig, wenn ein Kunde mehrere passende Bestellungen hat — sonst Duplikate.
Eine ausführlichere Performance-Diskussion mit Benchmarks: EXISTS vs. IN vs. JOIN.
Interessantes
Subquery liefert mehrere Zeilen — Type-Mismatch.
Ein skalarer Vergleich (x > (SELECT …)) erwartet GENAU eine Zeile. Liefert die Subquery mehrere, gibt's „more than one row returned by a subquery used as an expression". Lösung: Aggregat (max, min, avg) oder LIMIT 1 mit klarem ORDER BY.
IN (SELECT …) ist NICHT „SET CONTAINS“.
Manche denken, WHERE x IN (subquery) würde teure Set-Operationen machen. Tut Postgres meistens nicht — der Optimizer wandelt das oft in Semi-Join um (intern wie EXISTS). Bei großen Tabellen funktioniert das gut, mit den richtigen Indexen.
NOT IN mit NULL-haltiger Subquery — Killer-Bug.
Wenn customer_id in orders NULL-Werte hat, liefert WHERE id NOT IN (SELECT customer_id FROM orders) für jede Zeile NULL — also kein Treffer. Postgres-Verhalten ist standardkonform, aber überraschend. Saubere Variante: WHERE NOT EXISTS (SELECT 1 FROM orders WHERE customer_id = c.id).
Korrelierte Subquery in SELECT — vorsicht bei Performance.
SELECT (SELECT count(*) FROM orders WHERE customer_id = c.id) AS cnt FROM customers c läuft die Subquery pro Kunde — kann bei vielen Kunden teuer werden. Alternative: LEFT JOIN mit GROUP BY, oder ein LATERAL-Join. EXPLAIN ANALYZE zeigt, wie der Plan aussieht.
SELECT 1 FROM … in EXISTS ist Konvention.
Postgres ignoriert die SELECT-Liste in EXISTS — du könntest SELECT *, SELECT 1, SELECT col schreiben, alle gleich schnell. SELECT 1 ist nur ein lesbares Signal: „mir geht's um Existenz, nicht um Werte".
ANY und ALL sind Standard-SQL.
Viele kennen sie nicht, weil IN und MAX/MIN-Subqueries die häufigeren Formulierungen sind. Aber sie sind nützlich als Pattern-Match ('foo' = ANY(text_array)) — Postgres-Arrays nutzen denselben Operator.
Weiterführende Ressourcen
Externe Quellen
- Subquery Expressions – PostgreSQL Documentation
- SELECT Statement – PostgreSQL Documentation
- Comparison Operators