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.

SQL Beispiel-Tabellen
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');
SQL Alle Kombinationen
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 | XL

3 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:

SQL
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:

SQL Datums-Reihen generieren
-- 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).

SQL Test-Daten generieren
-- 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:

SQL Top-3-Bestellungen pro Kunde
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:

SQL
 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.95

Pro 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:

SQL Mit Carol (LEFT JOIN LATERAL)
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:

SQL Items-Array aufdröseln
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:

SQL
 customer | sku | qty
----------+-----+-----
 Alice    | A1  |   2
 Alice    | B2  |   1

Die Komma-Notation FROM o, jsonb_array_elements(...) ist ein impliziter LATERAL — Postgres erlaubt das für Set-Returning-Functions. Mit LATERAL explizit:

SQL
FROM order_payloads o
CROSS JOIN LATERAL jsonb_array_elements(o.data->'items') AS item

Funktional 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:

SQL Pro Bestellung: Total mit Steuer + Margin
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-CaseEmpfehlung
Alle Kombinationen zweier MengenCROSS JOIN
Lückenlose Reports (jeder Tag, jeder Kunde)CROSS JOIN mit generate_series + LEFT JOIN
Top-N pro GruppeLATERAL mit Subquery + LIMIT n
JSON-Arrays in Zeilen aufdröselnimplizit LATERAL via jsonb_array_elements
Berechnete Werte einmal definieren, mehrfach nutzenLATERAL als Mini-CTE
Korrelierte Subquery in FROMLATERAL (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

/ Weiter

Zurück zu Joins & Subqueries

Zur Übersicht