Neben den klassischen INNER/OUTER-Joins gibt es zwei Spezial-Formen, die in der Praxis weniger bekannt, aber sehr nützlich sind: CROSS JOIN für das kartesische Produkt (jedes mit jedem) und LATERAL JOIN für korrelierte Subqueries pro Zeile der linken Seite. Letzteres ist DAS Werkzeug für „Top-N pro Gruppe"-Queries und JSON-Array-Aufdröselungen.
CROSS JOIN — das kartesische Produkt
CROSS JOIN kombiniert jede Zeile der linken Tabelle mit jeder der rechten — ohne Bedingung. Hat eine linke Tabelle 100 Zeilen, eine rechte 50, kommt 5.000 Zeilen heraus.
CREATE TABLE colors (color text);
CREATE TABLE sizes (size text);
INSERT INTO colors VALUES ('red'), ('blue'), ('green');
INSERT INTO sizes VALUES ('S'), ('M'), ('L'), ('XL');myapp=> SELECT color, size FROM colors CROSS JOIN sizes;
color | size
-------+------
red | S
red | M
red | L
red | XL
blue | S
blue | M
blue | L
blue | XL
green | S
green | M
green | L
green | XL3 Farben × 4 Größen = 12 Zeilen. Praktisch für „alle Varianten generieren"-Patterns: alle möglichen Produktkombinationen, alle Tag-Kategorie-Paare, etc.
Alternative Schreibweisen — alle gleichbedeutend:
SELECT color, size FROM colors CROSS JOIN sizes;
SELECT color, size FROM colors, sizes; -- Komma-Notation
SELECT color, size FROM colors INNER JOIN sizes ON TRUE;Die Komma-Notation ist die älteste, aber gilt heute als unsauber — sie wird gerne mit fehlender Join-Bedingung verwechselt (häufiger Bug-Auslöser bei Anfängern). CROSS JOIN macht die Absicht explizit.
Wann CROSS JOIN?
Konkrete Anwendungsfälle:
-- Pro Kunde, pro Tag des Monats: gibt es eine Bestellung?
SELECT
c.id,
d.day,
count(o.id) AS orders_count
FROM customers c
CROSS JOIN generate_series('2026-05-01'::date,
'2026-05-31'::date,
'1 day'::interval) AS d(day)
LEFT JOIN orders o
ON o.customer_id = c.id
AND o.created_at::date = d.day
GROUP BY c.id, d.day
ORDER BY c.id, d.day;generate_series() produziert jeden Tag im Mai. Mit CROSS JOIN bekommt jeder Kunde 31 Zeilen — und der LEFT JOIN fügt die tatsächlichen Bestellungen dazu, oder lässt's bei NULL. Klassischer Trick für „lückenlose Reports" (kein Tag fehlt, auch wenn kein Verkauf war).
-- 100 zufällige Order-Zeilen erzeugen
INSERT INTO orders (customer_id, total)
SELECT
(random() * 10 + 1)::int,
(random() * 200 + 10)::numeric(10,2)
FROM generate_series(1, 100);LATERAL JOIN — Subquery pro Zeile
LATERAL ist der zweite Spezial-Join — und der unterschätzteste. Damit darf eine Subquery in der FROM-Klausel auf Spalten der linken Tabelle zugreifen. Das geht in normalen Subqueries nicht.
Klassisches Problem: für jeden Kunden die drei letzten Bestellungen. Ohne LATERAL muss man das mit ROW_NUMBER()-Window-Functions plus Subquery bauen. Mit LATERAL geht's direkt:
SELECT
c.name,
recent.id AS order_id,
recent.created_at,
recent.total
FROM customers c
CROSS JOIN LATERAL (
SELECT id, created_at, total
FROM orders
WHERE customer_id = c.id -- darf c.id referenzieren!
ORDER BY created_at DESC
LIMIT 3
) recent
ORDER BY c.name, recent.created_at DESC;Output:
name | order_id | created_at | total
-------+----------+------------------------+-------
Alice | 42 | 2026-05-07 11:00:00+02 | 99.95
Alice | 38 | 2026-05-05 14:00:00+02 | 49.95
Alice | 21 | 2026-04-28 10:00:00+02 | 19.95
Bob | 40 | 2026-05-06 18:00:00+02 | 19.95Pro Kunde maximal drei Zeilen, sortiert nach Datum. Carol kommt in diesem CROSS JOIN LATERAL-Pattern nicht vor (keine Bestellungen). Soll sie mit auftauchen, nimmt man LEFT JOIN LATERAL ... ON TRUE:
SELECT c.name, recent.total
FROM customers c
LEFT JOIN LATERAL (
SELECT total
FROM orders
WHERE customer_id = c.id
ORDER BY created_at DESC
LIMIT 1
) recent ON TRUE
ORDER BY c.name;ON TRUE sagt: alle Zeilen passen. In Kombination mit LEFT JOIN bedeutet das: für jeden Kunden alles aus der LATERAL-Subquery (ggf. NULL) übernehmen.
LATERAL für JSON-Array-Auflösung
jsonb_array_elements und ähnliche Set-Returning-Functions nutzen LATERAL implizit:
CREATE TABLE order_payloads (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
data jsonb
);
INSERT INTO order_payloads (data) VALUES (
'{
"customer": "Alice",
"items": [
{"sku": "A1", "qty": 2},
{"sku": "B2", "qty": 1}
]
}'
);
SELECT
o.data->>'customer' AS customer,
item->>'sku' AS sku,
(item->>'qty')::int AS qty
FROM order_payloads o,
jsonb_array_elements(o.data->'items') AS item;Output:
customer | sku | qty
----------+-----+-----
Alice | A1 | 2
Alice | B2 | 1Die Komma-Notation FROM o, jsonb_array_elements(...) ist ein impliziter LATERAL — Postgres erlaubt das für Set-Returning-Functions. Mit LATERAL explizit:
FROM order_payloads o
CROSS JOIN LATERAL jsonb_array_elements(o.data->'items') AS itemFunktional identisch, lesbarer für Leute, die LATERAL kennen.
LATERAL für berechnete Werte pro Zeile
LATERAL ist auch praktisch, um berechnete Zwischenwerte sauber zu strukturieren:
SELECT
o.id,
o.total,
calc.tax_amount,
calc.total_with_tax,
calc.is_high_value
FROM orders o
CROSS JOIN LATERAL (
SELECT
o.total * 0.19 AS tax_amount,
o.total * 1.19 AS total_with_tax,
o.total > 100 AS is_high_value
) calc;Du sparst dir, dieselben Ausdrücke im SELECT mehrfach zu schreiben. Variante zum „CTE im Mini-Format". Funktioniert auch in komplexeren Fällen, in denen mehrere abgeleitete Werte aufeinander aufbauen.
Zusammenfassung — wann was?
| Use-Case | Empfehlung |
|---|---|
| Alle Kombinationen zweier Mengen | CROSS JOIN |
| Lückenlose Reports (jeder Tag, jeder Kunde) | CROSS JOIN mit generate_series + LEFT JOIN |
| Top-N pro Gruppe | LATERAL mit Subquery + LIMIT n |
| JSON-Arrays in Zeilen aufdröseln | implizit LATERAL via jsonb_array_elements |
| Berechnete Werte einmal definieren, mehrfach nutzen | LATERAL als Mini-CTE |
| Korrelierte Subquery in FROM | LATERAL (sonst „cannot reference outer column") |
Besonderheiten
CROSS JOIN ohne Filter ist ein Footgun.
100.000 Zeilen × 100.000 Zeilen = 10 Milliarden — bringt jede DB zum Schwitzen. CROSS JOIN sollte fast immer mit klarem Resultat-Sizing eingesetzt werden (kleine Tabellen oder mit LIMIT/WHERE-Filter). Versehentlicher CROSS bei vergessener Join-Bedingung ist auch heute noch der häufigste „warum dauert die Query stundenlang?"-Auslöser.
Komma-Notation mit fehlender ON-Bedingung = unbeabsichtigter CROSS.
FROM customers, orders ohne WHERE customers.id = orders.customer_id ist ein implizit kartesisches Produkt — in alten SQL-Dialekten ein Klassiker der Fehlerquellen. Ein Grund, warum moderne Codebasen ausschließlich JOIN-Syntax nutzen.
LATERAL ohne Komma vor Subquery — leicht zu vergessen.
FROM customers c, (SELECT … WHERE customer_id = c.id) sub schlägt mit „invalid reference to FROM-clause entry" fehl. Korrekt: FROM customers c, LATERAL (SELECT … WHERE customer_id = c.id) sub. Ohne LATERAL darf eine Subquery in FROM nicht auf andere FROM-Tabellen verweisen.
LATERAL ist quasi „pro Zeile ausführen“.
Postgres führt die LATERAL-Subquery konzeptionell pro Zeile der äußeren Tabelle aus. Der Optimizer kann das zwar oft als Index-Loop oder Hash-Join umsetzen — Performance-mäßig sind LATERALs aber meist langsamer als äquivalente Joins. Wenn beides geht, normalen Join bevorzugen.
generate_series ist DAS Werkzeug für Datums-Reihen.
generate_series('2026-01-01'::date, '2026-12-31'::date, '1 day'::interval) produziert jeden Tag des Jahres als eigene Zeile. Mit CROSS JOIN gegen Kunden / Produkte / etc. baust du beliebige Pivot-Tabellen für Reports — ohne externe Calendar-Tabelle.
LATERAL + LIMIT 1 = „korrelierter Sub-SELECT“ mit mehr Spalten.
Ein klassischer Sub-SELECT (SELECT (SELECT max(total) FROM orders WHERE customer_id = c.id) FROM customers c) liefert genau eine Spalte. Wenn du mehrere Spalten aus dem „Top-1-Treffer" willst, ist LATERAL mit LIMIT 1 die saubere Lösung — ohne mehrfache Subqueries.
Weiterführende Ressourcen
Externe Quellen
- Joined Tables – LATERAL
- generate_series – PostgreSQL Documentation
- JSON Functions – jsonb_array_elements