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):
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: BerndFrage: „Wer arbeitet für wen?" Antwort: Self-Join.
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:
employee | manager
----------+---------
Anna | NULL <- CEO, kein Manager
Bernd | Anna
Carla | Anna
David | Bernd
Eva | BerndDer 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:
myapp=> SELECT employees.name FROM employees JOIN employees ON ...;
ERROR: table name "employees" specified more than onceJede 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:
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:
employee | manager | grand_manager
----------+---------+---------------
Anna | NULL | NULL
Bernd | Anna | NULL
Carla | Anna | NULL
David | Bernd | Anna
Eva | Bernd | AnnaBei 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:
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":
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:
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):
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
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:
manager | direct_reports
---------+----------------
Anna | 2
Bernd | 2
Carla | 0
David | 0
Eva | 0Zwei 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".