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
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:
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:
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:
- Wertebereich: numerisch (
> 0,BETWEEN, etc.) - Aufzählung:
IN (...)— Alternative zu Enum-Typ - Format: Pattern-Matching mit
LIKEoder 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:
-- 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
-- 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.
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
| CHECK | Trigger | Domain | |
|---|---|---|---|
| Logik | Pure Expression | Beliebig | Pure Expression + Type |
| Subqueries | Nein | Ja | Nein |
| Side-Effects | Nein | Ja | Nein |
| Performance | Sehr schnell | Langsamer | Sehr schnell |
| Geltungsbereich | Eine Tabelle | Eine Tabelle | Mehrere 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
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.