Eine Common Table Expression — kurz CTE — ist ein benannter Zwischenschritt in einer Query, definiert mit der WITH-Klausel. Statt verschachtelte Subqueries zu schreiben, gibst du jeder Teil-Berechnung einen Namen und referenzierst sie wie eine Tabelle. Das macht komplexe Queries deutlich lesbarer — und ist für rekursive Patterns (Hierarchien) der einzige Weg.
Die Grundform
WITH paid_orders AS (
SELECT * FROM orders WHERE status = 'paid'
)
SELECT customer_id, sum(total) AS revenue
FROM paid_orders
GROUP BY customer_id;WITH paid_orders AS (...) definiert eine virtuelle Tabelle namens paid_orders für die Dauer der Query. Im äußeren SELECT wird sie wie eine ganz normale Tabelle benutzt.
Funktional identisch zu einer Subquery in FROM:
SELECT customer_id, sum(total) AS revenue
FROM (
SELECT * FROM orders WHERE status = 'paid'
) paid_orders
GROUP BY customer_id;Bei einem einzelnen Filter ist die Subquery kürzer. Sobald die Logik verschachtelter wird (oder mehrfach gebraucht wird), gewinnt die CTE deutlich an Lesbarkeit.
Mehrere CTEs in einer Query
Du kannst mehrere CTEs aneinanderreihen — jeder kann auf vorherige zugreifen:
WITH
paid_orders AS (
SELECT * FROM orders WHERE status = 'paid'
),
customer_totals AS (
SELECT
customer_id,
sum(total) AS revenue,
count(*) AS order_count
FROM paid_orders
GROUP BY customer_id
),
top_customers AS (
SELECT *
FROM customer_totals
WHERE revenue > 100
)
SELECT c.name, t.revenue, t.order_count
FROM top_customers t
INNER JOIN customers c ON c.id = t.customer_id
ORDER BY t.revenue DESC;Liest sich von oben nach unten wie ein Mini-Programm:
paid_orders— alle bezahlten Bestellungencustomer_totals— pro Kunde aggregierttop_customers— nur die mit Umsatz > 100- Outer query — Join mit Kunden-Namen und Sortierung
Im Vergleich zur äquivalenten verschachtelten Subquery-Variante: deutlich klarer welcher Schritt was tut.
CTEs sind keine Variablen
Wichtige Eigenheit: jeder CTE-Verweis erzeugt konzeptionell dieselbe Auswertung. Du kannst dich aber nicht darauf verlassen, dass Postgres das Ergebnis zwischenspeichert (mehr im Artikel MATERIALIZED vs. NOT MATERIALIZED).
Was CTE nicht ist:
- Keine Variable — du kannst keinen Wert in einem CTE „berechnen und speichern".
- Keine echte temporäre Tabelle — keine Indexe, keine Wiederverwendbarkeit über Queries hinweg.
- Kein PL/pgSQL-Block — keine Schleifen, keine Variablen.
Dafür gibt's andere Werkzeuge: Variablen über LET (PG 17+ in Procedures), echte Temp-Tabellen (CREATE TEMPORARY TABLE), oder gespeicherte Funktionen.
Mehrfache Referenzierung
Ein CTE kann in derselben Query mehrmals verwendet werden:
WITH stats AS (
SELECT
avg(total) AS avg_order,
max(total) AS max_order
FROM orders WHERE status = 'paid'
)
SELECT
o.id,
o.total,
CASE
WHEN o.total > (SELECT avg_order FROM stats) THEN 'above_avg'
ELSE 'at_or_below_avg'
END AS bucket,
(SELECT max_order FROM stats) AS overall_max
FROM orders o
WHERE o.status = 'paid'
ORDER BY o.total DESC;stats wird zweimal referenziert (im CASE und in der overall_max-Spalte). Konzeptionell wird's einmal berechnet — Postgres entscheidet, ob's intern materialisiert wird.
CTE vs. Subquery — wann was?
| Pattern | Wann nutzen |
|---|---|
Subquery in FROM | für einzelne Zwischenschritte, kompakt |
| CTE | wenn ein Schritt mehrfach referenziert wird |
| CTE | für komplexere Pipelines mit 3+ Schritten |
| CTE | für rekursive Logik (WITH RECURSIVE) |
| CTE | wenn Lesbarkeit wichtiger ist als Kompaktheit |
In modernen Postgres-Versionen (12+) ist die Performance zwischen CTE und Subquery oft identisch — der Optimizer transformiert CTEs intern wie Subqueries (es sei denn, sie sind als MATERIALIZED markiert oder rekursiv).
Faustregel: bei 3 oder mehr Schritten lieber CTEs verwenden — die Lesbarkeit gewinnt.
Spalten-Aliasse
Du kannst die Spalten eines CTEs explizit benennen:
WITH revenue_by_country (country, total_revenue, customer_count) AS (
SELECT
c.country,
sum(o.total),
count(DISTINCT c.id)
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid'
GROUP BY c.country
)
SELECT * FROM revenue_by_country
ORDER BY total_revenue DESC;Die Klammer hinter dem CTE-Namen definiert die Spalten-Namen für die Außenansicht. Selten gebraucht (man kann auch im SELECT direkt benennen), aber praktisch wenn die Spalten-Logik weit oben verständlich sein soll.
Praxis-Beispiele
Top-Kunden mit Anteil am Gesamtumsatz
WITH
revenue_per_customer AS (
SELECT
customer_id,
sum(total) AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
),
global_total AS (
SELECT sum(revenue) AS total FROM revenue_per_customer
)
SELECT
c.name,
r.revenue,
ROUND(100.0 * r.revenue / g.total, 2) AS pct_of_total
FROM revenue_per_customer r
CROSS JOIN global_total g
INNER JOIN customers c ON c.id = r.customer_id
ORDER BY r.revenue DESC
LIMIT 10;Drei klare Schritte: pro Kunde aggregieren, globalen Total bilden, Anteile berechnen. Mit verschachtelten Subqueries würde das schnell unleserlich.
Bereinigung mit CTE
WITH cleaned_orders AS (
SELECT
id,
customer_id,
total,
COALESCE(status, 'unknown') AS status,
date_trunc('day', created_at)::date AS day
FROM orders
WHERE total > 0
AND created_at IS NOT NULL
)
SELECT
day,
status,
count(*) AS order_count,
sum(total) AS revenue
FROM cleaned_orders
GROUP BY day, status
ORDER BY day DESC, status;Bereinigung als ersten Schritt isolieren — die folgenden Aggregate werden lesbarer.
Interessantes
CTE oder Subquery — Performance ist heute meist gleich.
Vor PG 12 hat Postgres CTEs immer materialisiert (Daten zwischengespeichert) — das war oft langsamer als Subqueries. Seit PG 12 inlinet der Planner CTEs standardmäßig wie Subqueries. Wer das alte Verhalten will: WITH ... AS MATERIALIZED (...) schreiben.
CTEs sehen sich nur rückwärts.
Der zweite CTE kann auf den ersten zugreifen, der erste nicht auf den zweiten. Auch keine zirkulären Referenzen — außer bei WITH RECURSIVE. Das ist gewollt: macht die Auswertungs-Reihenfolge eindeutig.
CTEs leben nur in einer Query.
Anders als Views oder Temp-Tabellen sind CTEs an die enthaltende Query gebunden. Wer dieselbe Logik in mehreren Queries braucht, nimmt eine VIEW (oder eine echte Temp-Tabelle bei großen Zwischen-Resultaten).
WITH kann auch Daten ändern.
WITH del AS (DELETE FROM ... RETURNING *) INSERT INTO archive SELECT * FROM del — Data-Modifying-CTEs erlauben es, mehrere Schreib-Operationen in einer Transaktion zu verketten. Eigener Artikel: Data-Modifying-CTEs.
WITH RECURSIVE für Hierarchien.
Standard-CTEs sind nicht-rekursiv. Mit WITH RECURSIVE kann ein CTE auf sich selbst referenzieren — der einzige Weg, Hierarchien dynamischer Tiefe zu queryen (Mitarbeiter-Manager-Bäume, Datei-Strukturen etc.). Eigener Artikel: Rekursive CTEs.
CTEs sind SQL-Standard — funktionieren überall.
Postgres, Oracle, SQL Server, DB2, MariaDB 10.2+, MySQL 8+, SQLite 3.8+ — alle unterstützen WITH. Cross-DB-portabel, anders als manche andere Postgres-Spezialitäten.