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

SQL FK in CREATE TABLE
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 orders muss die customer_id auf eine existierende customers.id zeigen.
  • Beim DELETE/UPDATE in customers darf die referenzierte Zeile nicht weg, solange orders darauf verweist (Default = NO ACTION).

ON DELETE-Strategien

Fünf Optionen, was beim Löschen der Parent-Zeile passiert:

OptionVerhalten
NO ACTION (Default)Fehler beim DELETE, falls Verweise existieren — am Ende der Statement-Prüfung
RESTRICTFehler beim DELETE, sofort geprüft (nicht aufschiebbar)
CASCADEAlle verweisenden Child-Zeilen werden mitgelöscht
SET NULLFK-Spalte in Children wird auf NULL gesetzt (FK-Spalte muss NULL erlauben)
SET DEFAULTFK-Spalte wird auf den Default-Wert gesetzt — das Default muss seinerseits valide sein
SQL ON DELETE-Beispiele
-- 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_items an orders: kaskadieren — eine Bestellung ohne Items macht keinen Sinn
  • order_items an products: restriktiv — Produkte mit Bestellhistorie können nicht einfach weg
  • employees an employees (Manager): NULL — wenn Manager geht, ist Mitarbeiter erstmal manager-los

ON UPDATE

Analoge Optionen wie ON DELETE, aber für UPDATE der Parent-Spalte.

SQL
REFERENCES customers(id) ON UPDATE CASCADE

In 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.

SQL Manuell den Index anlegen
CREATE INDEX orders_customer_id_idx ON orders (customer_id);

Ohne diesen Index:

  • Jeder DELETE FROM customers WHERE id = X muss orders per Sequential Scan durchsuchen, ob X referenziert wird
  • Genauso bei UPDATE der 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:

SQL Aufgeschobene Prüfung
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:

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

Use-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

SQL Mit NOT VALID + VALIDATE
-- 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:

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

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Constraints & Schema-Design

Zur Übersicht