Ein Self-Join ist nichts Spezielles — du joinst eine Tabelle einfach mit sich selbst, mit unterschiedlichen Aliassen für die zwei „Kopien". Das Pattern wirkt erst seltsam, ist aber für Hierarchien (Mitarbeiter und ihre Manager), Zeilen-Vergleiche und Duplikat-Suche unverzichtbar.

Klassisches Beispiel: Mitarbeiter und Manager

Das Schulbuch-Beispiel — eine employees-Tabelle, in der jeder Mitarbeiter einen Verweis auf seinen Manager hat (selbst ein Mitarbeiter):

SQL Selbst-referenzierende Tabelle
CREATE TABLE employees (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name       text NOT NULL,
    manager_id bigint REFERENCES employees(id)
);

INSERT INTO employees (name, manager_id) VALUES
    ('Anna',   NULL),  -- id 1, CEO, kein Manager
    ('Bernd',  1),     -- id 2, Manager: Anna
    ('Carla',  1),     -- id 3, Manager: Anna
    ('David',  2),     -- id 4, Manager: Bernd
    ('Eva',    2);     -- id 5, Manager: Bernd

Frage: „Wer arbeitet für wen?" Antwort: Self-Join.

SQL Mitarbeiter und ihre direkten Manager
SELECT
    e.name      AS employee,
    m.name      AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id
ORDER BY m.name NULLS FIRST, e.name;

Output:

SQL
 employee | manager
----------+---------
 Anna     | NULL      <- CEO, kein Manager
 Bernd    | Anna
 Carla    | Anna
 David    | Bernd
 Eva      | Bernd

Der Trick: Tabelle employees einmal als e (Mitarbeiter) und einmal als m (Manager). Die Verknüpfung läuft über m.id = e.manager_id. LEFT JOIN, weil Anna keinen Manager hat — sie soll trotzdem im Resultat sein.

Aliasse sind Pflicht

Bei Self-Join MUSS man Aliasse benutzen — sonst weiß Postgres nicht, welche Tabelle gemeint ist:

SQL
myapp=> SELECT employees.name FROM employees JOIN employees ON ...;
ERROR:  table name "employees" specified more than once

Jede Kopie braucht einen eindeutigen Namen — typische Konvention: kurze, beschreibende Aliasse (e, m, parent, child, etc.).

Mehr als zwei Ebenen — und warum dann CTE besser ist

Self-Joins sind statisch — sie können eine feste Anzahl von Ebenen abdecken:

SQL Drei Ebenen: Mitarbeiter, Manager, Manager des Managers
SELECT
    e.name      AS employee,
    m.name      AS manager,
    mm.name     AS grand_manager
FROM employees e
LEFT JOIN employees m  ON m.id = e.manager_id
LEFT JOIN employees mm ON mm.id = m.manager_id;

Output:

SQL
 employee | manager | grand_manager
----------+---------+---------------
 Anna     | NULL    | NULL
 Bernd    | Anna    | NULL
 Carla    | Anna    | NULL
 David    | Bernd   | Anna
 Eva      | Bernd   | Anna

Bei dynamischen Hierarchien mit unbekannter Tiefe (z. B. „alle Vorgesetzten bis zum CEO") wird das schnell unbequem — du müsstest pro möglicher Tiefe einen JOIN dazuschreiben. Dafür gibt es rekursive CTEs, eigenes Thema im Kapitel CTEs:

SQL Rekursive Variante (Vorschau)
WITH RECURSIVE chain AS (
    SELECT id, name, manager_id, 1 AS level
    FROM employees WHERE name = 'David'
    UNION ALL
    SELECT e.id, e.name, e.manager_id, chain.level + 1
    FROM employees e
    JOIN chain ON chain.manager_id = e.id
)
SELECT name, level FROM chain;

Faustregel: bis 2-3 fixe Ebenen → Self-Join. Mehr oder dynamisch → rekursive CTE.

Self-Join für Vergleiche zwischen Zeilen

Self-Joins sind nicht nur für Hierarchien. Auch für „Zeilen mit anderen Zeilen vergleichen":

SQL Bestellungen mit dem Vorgänger desselben Kunden
SELECT
    o.id                                 AS order_id,
    o.customer_id,
    o.created_at                         AS this_order,
    prev.created_at                      AS previous_order,
    o.created_at - prev.created_at       AS days_between
FROM orders o
LEFT JOIN orders prev
    ON prev.customer_id = o.customer_id
   AND prev.created_at < o.created_at
ORDER BY o.id, prev.created_at DESC;

Hier joint die orders-Tabelle mit sich selbst — prev sind Vorgänger-Bestellungen desselben Kunden. Eine Zeile pro (order, vorheriger order)-Paar. Wenn man nur den direkten Vorgänger will (eine Zeile pro Order), kombiniert man's mit DISTINCT ON oder einer Window-Function:

SQL
SELECT DISTINCT ON (o.id)
    o.id,
    o.created_at,
    prev.created_at AS previous_order
FROM orders o
LEFT JOIN orders prev
    ON prev.customer_id = o.customer_id
   AND prev.created_at < o.created_at
ORDER BY o.id, prev.created_at DESC;

Pro o.id gibt's eine Zeile, mit dem direkt vorhergehenden prev (DESC, dann LIMIT-Effekt durch DISTINCT ON).

In Window-Function-Schreibweise wäre das LAG(created_at) OVER (PARTITION BY customer_id ORDER BY created_at) — siehe Kapitel Window-Functions. Beide Varianten sind valide; die Window-Variante ist meist eleganter und schneller.

Self-Join für Duplikat-Suche

Wenn du Duplikate finden willst (basierend auf bestimmten Spalten):

SQL Duplikate in customers nach E-Mail
SELECT
    a.id   AS id_a,
    b.id   AS id_b,
    a.email
FROM customers a
INNER JOIN customers b
    ON a.email = b.email
   AND a.id < b.id            -- vermeidet (a,b) und (b,a) doppelt
ORDER BY a.email;

Der Trick a.id < b.id sorgt dafür, dass jedes Duplikat-Paar genau einmal auftaucht. Ohne diesen Filter bekämst du (1, 2) und (2, 1) als zwei Zeilen.

Self-Join + Aggregat: Statistiken pro Gruppe

SQL Wieviele Mitarbeiter hat jeder Manager?
SELECT
    m.name        AS manager,
    count(e.id)   AS direct_reports
FROM employees m
LEFT JOIN employees e ON e.manager_id = m.id
GROUP BY m.id, m.name
ORDER BY direct_reports DESC, m.name;

Output:

SQL
 manager | direct_reports
---------+----------------
 Anna    |              2
 Bernd   |              2
 Carla   |              0
 David   |              0
 Eva     |              0

Zwei Manager (Anna, Bernd) mit je zwei Direct-Reports. Carla, David und Eva haben keine — sie kommen wegen LEFT JOIN trotzdem vor, mit count = 0.

FAQ

Wann Self-Join, wann rekursive CTE?

Self-Join für fixe Anzahl Ebenen (1-3). Rekursive CTE für variable Tiefe — etwa „alle Vorgesetzten bis zum CEO" oder „kompletter Baum unter diesem Knoten". Die rekursive Variante ist mächtiger, aber etwas komplizierter zu lesen.

Warum brauchen Self-Joins zwingend Aliasse?

Ohne Alias hätten zwei Vorkommen derselben Tabelle keine eindeutige Adressierung — Postgres weiß nicht, welche Spalte gemeint ist. Das ist auch in normalen Joins so, fällt nur bei Self-Joins zwingend auf. Konvention: kurze, sprechende Aliasse (e, m, parent, child).

Self-Join über große Tabelle = doppelte Datenmenge im Speicher.

Eine orders-Tabelle mit 10 Mio. Zeilen, mit sich selbst gejoint, ist potenziell 10⁷ × 10⁷ Zeilen — natürlich filtert Postgres früh, aber der Optimizer-Plan kann teuer werden. Bei großen Tabellen unbedingt mit gut platzierten Indexen (customer_id, created_at) und EXPLAIN ANALYZE arbeiten.

Window-Functions oft eleganter für „vorheriger/nächster“-Pattern.

LAG(col) OVER (PARTITION BY group ORDER BY col2) und LEAD(col) OVER (...) sind oft kürzer und schneller als ein Self-Join für „mit Vorgänger/Nachfolger vergleichen". Im Kapitel Window Functions ausführlich. Self-Join bleibt aber die Variante, die ohne PG-Window-Kenntnisse universell verständlich ist.

a.id < b.id-Trick für Duplikat-Suche.

Ohne diesen Filter würden Duplikat-Paare doppelt vorkommen — einmal (1,2), einmal (2,1). Mit a.id < b.id (oder generell ein eindeutiges Sortier-Kriterium) bekommst du jedes Paar genau einmal. Klassischer Trick für „Paare aus derselben Tabelle, ungeordnet".

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Joins & Subqueries

Zur Übersicht