Ein Foreign Key garantiert, dass ein Wert in einer Tabelle auf eine existierende Zeile in einer anderen Tabelle verweist. Postgres zwingt das zur Schreibzeit ab — und beim Löschen der Parent-Zeile entscheidet die ON DELETE-Klausel, was passiert. Hier alle Varianten und der Klassiker-Stolperstein: Postgres legt KEINEN Index auf der FK-Spalte automatisch an.
Die Grundform
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(id),
total numeric(10,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);REFERENCES customers(id) ist die Inline-Form. Was sie garantiert:
- Beim INSERT/UPDATE in
ordersmuss diecustomer_idauf eine existierendecustomers.idzeigen. - Beim DELETE/UPDATE in
customersdarf die referenzierte Zeile nicht weg, solangeordersdarauf verweist (Default = NO ACTION).
ON DELETE-Strategien
Fünf Optionen, was beim Löschen der Parent-Zeile passiert:
| Option | Verhalten |
|---|---|
NO ACTION (Default) | Fehler beim DELETE, falls Verweise existieren — am Ende der Statement-Prüfung |
RESTRICT | Fehler beim DELETE, sofort geprüft (nicht aufschiebbar) |
CASCADE | Alle verweisenden Child-Zeilen werden mitgelöscht |
SET NULL | FK-Spalte in Children wird auf NULL gesetzt (FK-Spalte muss NULL erlauben) |
SET DEFAULT | FK-Spalte wird auf den Default-Wert gesetzt — das Default muss seinerseits valide sein |
-- Bestellungen mit ihren Items: Items mitlöschen
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id bigint NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
quantity int NOT NULL,
PRIMARY KEY (order_id, product_id)
);
-- Mitarbeiter: bei Manager-Wegfall einfach NULL setzen
CREATE TABLE employees (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
manager_id bigint REFERENCES employees(id) ON DELETE SET NULL
);Drei verschiedene Strategien, jeweils richtig für ihren Fall:
order_itemsanorders: kaskadieren — eine Bestellung ohne Items macht keinen Sinnorder_itemsanproducts: restriktiv — Produkte mit Bestellhistorie können nicht einfach wegemployeesanemployees(Manager): NULL — wenn Manager geht, ist Mitarbeiter erstmal manager-los
ON UPDATE
Analoge Optionen wie ON DELETE, aber für UPDATE der Parent-Spalte.
REFERENCES customers(id) ON UPDATE CASCADEIn der Praxis selten gebraucht: PKs sollten stabil sein. Bei Surrogate-IDs (Identity, UUID) ändert sich der PK normalerweise nie. Bei Natural Keys (z. B. SKUs) kann es relevant werden.
Der Klassiker-Stolperstein: kein Auto-Index auf FK
Postgres legt keinen Index auf der FK-Spalte automatisch an. Das ist absichtlich (FK-Spalten sind nicht immer sinnvoll als Index), aber bei großen Tabellen schmerzhaft.
CREATE INDEX orders_customer_id_idx ON orders (customer_id);Ohne diesen Index:
- Jeder
DELETE FROM customers WHERE id = Xmussordersper Sequential Scan durchsuchen, ob X referenziert wird - Genauso bei
UPDATEder referenzierten Spalte - Bei Millionen Zeilen in
orders: jeder Customer-DELETE wird zur Minutenangelegenheit
Faustregel: immer einen Index auf jeder FK-Spalte anlegen — Ausnahmen brauchen ein gutes Argument.
DEFERRABLE-Constraints
Standard-FK-Checks laufen sofort beim Statement. Mit DEFERRABLE lassen sie sich auf das Transaktions-Ende verschieben:
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id) REFERENCES customers(id)
DEFERRABLE INITIALLY IMMEDIATE
);In einer Transaktion verschieben:
BEGIN;
SET CONSTRAINTS orders_customer_fk DEFERRED;
-- jetzt darf temporär ein orders-Eintrag ohne passenden customer existieren
INSERT INTO orders (customer_id, total) VALUES (999, 100);
INSERT INTO customers (id, name) OVERRIDING SYSTEM VALUE VALUES (999, 'Bob');
COMMIT; -- jetzt prüft Postgres beide FK-ConstraintsUse-Case: zirkuläre FKs (zwei Tabellen, die aufeinander verweisen) oder Bulk-Imports, wo die Reihenfolge nicht stimmt.
DEFERRABLE INITIALLY DEFERRED macht die Verschiebung zum Default — ohne explizites SET CONSTRAINTS.
FK nachträglich hinzufügen
-- 1. Constraint anlegen, ohne bestehende Daten zu prüfen (kurzer Lock)
ALTER TABLE orders
ADD CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id) REFERENCES customers(id)
NOT VALID;
-- 2. Bestehende Daten in einem zweiten Schritt prüfen (kein exklusiver Lock)
ALTER TABLE orders
VALIDATE CONSTRAINT orders_customer_fk;Ohne NOT VALID würde Postgres bei ADD CONSTRAINT die ganze Tabelle scannen, mit exklusivem Lock — bei großen Tabellen lange Downtime. Mit NOT VALID ist der Lock kurz, der spätere VALIDATE läuft mit schwächerem Lock.
FK auf zusammengesetzte Spalten
Wenn der Parent-PK aus mehreren Spalten besteht:
CREATE TABLE warehouse_stock (
warehouse_id bigint NOT NULL,
product_id bigint NOT NULL,
quantity int NOT NULL,
PRIMARY KEY (warehouse_id, product_id)
);
CREATE TABLE stock_transfers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
from_warehouse bigint NOT NULL,
from_product bigint NOT NULL,
transferred_qty int NOT NULL,
FOREIGN KEY (from_warehouse, from_product)
REFERENCES warehouse_stock(warehouse_id, product_id)
);Spalten-Anzahl und -Typen müssen übereinstimmen.
Häufige Stolperfallen
Index auf FK-Spalte fehlt — DELETE/UPDATE wird langsam.
Postgres legt keinen Auto-Index an. Ohne manuellen Index: jeder Parent-DELETE/UPDATE löst Sequential Scan in jeder Child-Tabelle aus. Prüfen mit pg_stat_user_tables.seq_scan. Faustregel: jede REFERENCES-Spalte bekommt einen Index.
ON DELETE CASCADE bei flachen Hierarchien meist okay — bei tiefen tückisch.
Cascade über mehrere Ebenen kann beim DELETE eines einzigen Parent-Records riesige Mengen löschen — ohne Warnung. Bei kritischen Stamm-Tabellen lieber RESTRICT und explizite Aufräum-Logik. Wer's einsetzt: Code-Review verlangen.
NO ACTION vs. RESTRICT — feiner Unterschied.
Beide verbieten DELETE bei verweisenden Children. NO ACTION ist DEFERRABLE-fähig (am Statement-Ende geprüft, kann verschoben werden), RESTRICT läuft sofort und ist nicht aufschiebbar. In 99 % der Fälle ist NO ACTION (= Default) genau richtig.
SET NULL erfordert NULL-fähige FK-Spalte.
Logisch klar, aber häufig vergessen: wenn die FK-Spalte NOT NULL ist, scheitert ON DELETE SET NULL zur Laufzeit. Postgres erkennt das beim CONSTRAINT-Anlegen nicht — erst beim DELETE-Versuch.
Self-Referenz mit CASCADE kann ganzen Baum löschen.
manager_id REFERENCES employees(id) ON DELETE CASCADE löscht beim Entfernen einer Top-Manager-Zeile rekursiv die ganze Abteilung. Bei Hierarchien lieber SET NULL oder RESTRICT und manuell aufräumen.
Cross-Schema-FKs sind möglich, aber binden die Schemas.
REFERENCES other_schema.table(id) funktioniert. Aber: das Ziel-Schema kann nicht mehr gedroppt werden, ohne den FK-Constraint zu lösen. Bei stark gekoppelten Schemas okay; sonst eher vermeiden.