CHECK-Constraints sind boolesche Ausdrücke, die für jede Zeile wahr sein müssen. Damit zwingst du Domain-Logik direkt im Schema ab — nicht jede App-Schicht muss daran denken. Hier alle Varianten von Spalten-CHECK über Multi-Spalten-Constraints bis zu sicherem Nachrüsten ohne Lock.

Einfache CHECKs auf einer Spalte

SQL
CREATE TABLE products (
    id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name  text   NOT NULL,
    price numeric(10,2) NOT NULL CHECK (price >= 0),
    stock int    NOT NULL DEFAULT 0 CHECK (stock >= 0)
);

CHECK (price >= 0) muss für jede Zeile wahr sein. Versuch eines INSERT mit negativem Preis → Fehler.

NULL-Verhalten: CHECK lässt NULL durch (NULL ist weder TRUE noch FALSE — und nur FALSE blockiert). Wer NULL ausschließen will: zusätzlich NOT NULL.

CHECK über mehrere Spalten

Constraints können auf andere Spalten derselben Zeile zugreifen — als Tabellen-Constraint geschrieben:

SQL Plausibilität zwischen Spalten
CREATE TABLE bookings (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    check_in   date NOT NULL,
    check_out  date NOT NULL,
    guests     int  NOT NULL,
    CONSTRAINT valid_dates CHECK (check_out > check_in),
    CONSTRAINT valid_guests CHECK (guests > 0 AND guests <= 10)
);

Mit dem benannten Constraint (CONSTRAINT valid_dates CHECK (...)) gibt's bessere Fehlermeldungen und stabile Namen für spätere Migrationen.

Wertebereich, Pattern, Aufzählung

Ein paar typische Patterns:

SQL Verschiedene CHECK-Patterns
CREATE TABLE orders (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    total  numeric(10,2) NOT NULL,
    status text NOT NULL,
    email  text,

    CHECK (total > 0),
    CHECK (status IN ('pending','paid','shipped','cancelled')),
    CHECK (email IS NULL OR email LIKE '%_@_%._%'),
    CHECK (length(status) <= 20)
);

Drei häufige Patterns:

  1. Wertebereich: numerisch (> 0, BETWEEN, etc.)
  2. Aufzählung: IN (...) — Alternative zu Enum-Typ
  3. Format: Pattern-Matching mit LIKE oder Regex (~)

Bei Aufzählungen die Wahl: CHECK (status IN (...)) oder eigener Enum-Typ. Enum-Typen sind effizienter, aber weniger flexibel beim Erweitern (siehe ENUM).

Was CHECK NICHT kann

CHECK-Ausdrücke müssen deterministisch und immutable sein. Folgendes ist verboten:

SQL Verbotene Patterns
-- Subqueries: VERBOTEN
CHECK (customer_id IN (SELECT id FROM customers))
--                ^ Foreign Key nehmen, nicht CHECK

-- now(), random(), current_user etc.: VERBOTEN als CHECK-Ausdruck
CHECK (created_at <= now())
-- Postgres erlaubt es zwar formal, aber später-eingefügte Daten
-- können den Check brechen, wenn now() sich „weiter dreht"

-- Volatile Funktionen: VERBOTEN
CHECK (random() < 0.5)

Begründung: ein CHECK muss zur Schreib-Zeit auf einer einzelnen Zeile entscheidbar sein, ohne andere Tabellen oder externe Zustände abzufragen. Wer mehr Logik braucht: Trigger.

CHECK nachträglich hinzufügen — mit NOT VALID

SQL Bestand nicht prüfen, neue Zeilen schon
-- 1. Constraint anlegen, ohne Bestandsprüfung
ALTER TABLE products
ADD CONSTRAINT price_non_negative
CHECK (price >= 0)
NOT VALID;

-- 2. Später Bestand sauber machen
UPDATE products SET price = 0 WHERE price < 0;

-- 3. Constraint validieren (kein Lock auf SELECT/INSERT)
ALTER TABLE products VALIDATE CONSTRAINT price_non_negative;

NOT VALID ist der Schlüssel für sichere Schema-Migrationen großer Tabellen:

  • Schritt 1 ist ein kurzer Lock — kein Tabellen-Scan
  • Neue INSERTs/UPDATEs werden ab sofort geprüft
  • Schritt 3 (VALIDATE) macht den Tabellen-Scan, aber mit schwachem Lock

CHECK auf JSONB-Feldern

Spannender Use-Case: Strukturen in JSONB validieren.

SQL
CREATE TABLE events (
    id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    data  jsonb NOT NULL,
    CHECK (jsonb_typeof(data->'amount') = 'number'),
    CHECK (data ? 'event_type'),
    CHECK ((data->>'event_type') IN ('click','view','purchase'))
);

So lässt sich ein gewisses Schema auch in „schemaless" JSONB-Spalten erzwingen — nicht so streng wie ein klassisches Schema, aber besser als nichts.

CHECK vs. Trigger vs. Domain

CHECKTriggerDomain
LogikPure ExpressionBeliebigPure Expression + Type
SubqueriesNeinJaNein
Side-EffectsNeinJaNein
PerformanceSehr schnellLangsamerSehr schnell
GeltungsbereichEine TabelleEine TabelleMehrere Tabellen

Faustregel:

  • Einfache Domain-Logik → CHECK
  • Plausibilitäts-Logik mit Subqueries oder Side-Effects → Trigger
  • Wiederverwendete Domain-Logik (z. B. Email-Validation an mehreren Stellen) → Domain
SQL Domain als wiederverwendbarer CHECK
CREATE DOMAIN email_address AS text
    CHECK (VALUE LIKE '%_@_%._%');

CREATE TABLE users    (id bigint, email email_address);
CREATE TABLE contacts (id bigint, email email_address);

Besonderheiten

CHECK lässt NULL durch — bewusst nutzen.

CHECK (price >= 0) ist bei price IS NULL „nicht falsch" und damit erlaubt. Wer NULL ausschließt: explizit NOT NULL plus CHECK. Manchmal aber gewollt (NULL = „unbekannt", nicht „negativ").

NOT VALID ist der saubere Weg für Bestands-Tabellen.

Ohne NOT VALID scannt Postgres bei ADD CONSTRAINT die ganze Tabelle mit ACCESS EXCLUSIVE Lock — bei Millionen-Tabellen Minuten Downtime. NOT VALID macht den Hinzufüge-Schritt instant; das spätere VALIDATE braucht nur einen schwächeren Lock.

CHECK kann STABLE-Funktionen nutzen, keine VOLATILE.

CHECK (col = upper(col)) ist okay (upper ist IMMUTABLE). CHECK (col >= now() - interval '1 day') läuft beim Insert durch, aber ist konzeptionell falsch — Bestandszeilen, die mal valide waren, sind morgen nicht mehr valide. Nicht so verwenden.

CHECKs auf Domains greifen für ALLE Tabellen mit dem Domain-Typ.

CREATE DOMAIN email AS text CHECK (...) — überall wo email als Spaltentyp benutzt wird, gilt der Check. Ändert man den Domain-Check später, wird der Bestand neu validiert. Powerful, aber auch hidden — Schema-Reviewer müssen Domains beachten.

Mehrere CHECKs sind separat nutzbar — Reihenfolge egal.

Postgres prüft alle CHECK-Constraints einer Zeile, bis einer FALSE liefert. Reihenfolge ist nicht definiert. Bei vielen CHECKs kann's marginal Performance kosten — bei normalen Tabellen ist das aber nicht messbar.

citext oder lower()-CHECK für Case-Insensitivity.

Wer Unique nicht-case-sensitive haben will: kombinieren mit Unique-Index auf lower(...) oder ein eigener Domain mit CHECK (col = lower(col)) — dann sind nur lowercase-Werte erlaubt, und Eindeutigkeit ist trivial.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Constraints & Schema-Design

Zur Übersicht