Exclusion Constraints sind ein Postgres-Spezial-Feature: sie verhindern, dass zwei Zeilen einen Konflikt nach einem frei wählbaren Operator haben. Klassisches Beispiel: keine zwei Buchungen für den selben Raum dürfen sich zeitlich überschneiden. UNIQUE prüft Gleichheit — EXCLUDE prüft beliebige Operatoren wie && (Range-Overlap).
Das Standard-Beispiel: Buchungs-Überschneidung
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE room_bookings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id bigint NOT NULL,
during tstzrange NOT NULL,
EXCLUDE USING gist (
room_id WITH =,
during WITH &&
)
);Was steht da:
room_id WITH =— wenn zwei Zeilen die selberoom_idhaben…during WITH &&— und sich zeitlich überschneiden (&&= Range-Overlap-Operator)- …dann ist das ein Konflikt → INSERT/UPDATE wird abgelehnt.
Test:
INSERT INTO room_bookings (room_id, during) VALUES
(1, '[2026-05-07 10:00, 2026-05-07 12:00)'); -- ok
INSERT INTO room_bookings (room_id, during) VALUES
(1, '[2026-05-07 13:00, 2026-05-07 15:00)'); -- ok, kein Overlap
INSERT INTO room_bookings (room_id, during) VALUES
(1, '[2026-05-07 11:30, 2026-05-07 14:00)'); -- ERROR: conflicting keyDer dritte Insert scheitert — er überschneidet sich mit dem ersten.
Warum btree_gist?
Der GiST-Index unterstützt nativ Range-Operatoren (&&), aber nicht den Gleichheits-Operator auf einfachen Typen wie bigint.
Die btree_gist-Extension lehrt den GiST-Index, auch = auf Standard-Typen zu indizieren — damit kann ein einziger GiST-Index beide Spalten (room_id = und during &&) gemeinsam abdecken.
Ohne btree_gist würde der Constraint nur funktionieren, wenn alle Spalten Range-Typen sind oder Postgres-spezielle Typen wie inet.
Range-Typen im Überblick
EXCLUDE-Constraints arbeiten typisch mit Range-Typen:
| Typ | Bedeutung |
|---|---|
int4range, int8range, numrange | Numerische Ranges |
tsrange | Range über timestamp |
tstzrange | Range über timestamptz (timezone-aware) |
daterange | Range über date |
Range-Literale-Beispiele:
'[10, 20)'::int4range -- 10 ≤ x < 20
'(2026-01-01, 2026-12-31]'::daterange -- exklusiv unten, inklusiv oben
'[2026-05-07 10:00, 2026-05-07 12:00)'::tstzrangeEckige Klammern: inklusiv. Runde Klammern: exklusiv. Mehr im Artikel Ranges.
Operator-Wahl
Der WITH-Operator definiert, was als Konflikt gilt:
| Operator | Bedeutung | Use-Case |
|---|---|---|
= | Gleichheit | wie UNIQUE-Logik |
&& | Range-Overlap | Zeit-/Wertebereich-Überschneidung |
<> | Ungleichheit | „alle müssen gleich sein" |
~ | Pattern-Match | seltener |
EXCLUDE mit = ist äquivalent zu UNIQUE. Der spannende Use-Case ist && für Ranges.
Praxis-Beispiele
Hotel-Zimmer pro Person
CREATE TABLE stays (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
guest_id bigint NOT NULL,
room_id bigint NOT NULL,
during tstzrange NOT NULL,
-- Ein Gast kann nicht in zwei Zimmern gleichzeitig sein
EXCLUDE USING gist (guest_id WITH =, during WITH &&),
-- Ein Zimmer kann nicht doppelt belegt sein
EXCLUDE USING gist (room_id WITH =, during WITH &&)
);Zwei separate EXCLUDE-Constraints — Postgres prüft beide.
Mit Bedingung: nur aktive Reservierungen
CREATE TABLE meetings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id bigint NOT NULL,
during tstzrange NOT NULL,
status text NOT NULL,
EXCLUDE USING gist (room_id WITH =, during WITH &&)
WHERE (status = 'confirmed')
);WHERE (status = 'confirmed') — der Constraint greift nur für bestätigte Meetings. 'cancelled'-Meetings dürfen sich überschneiden.
Preis-Historie ohne Lücken-Konflikt
CREATE TABLE product_prices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id bigint NOT NULL,
price numeric(10,2) NOT NULL,
valid_during tstzrange NOT NULL,
EXCLUDE USING gist (product_id WITH =, valid_during WITH &&)
);Pro Produkt darf zu jedem Zeitpunkt nur ein Preis gelten — keine zwei sich überschneidenden Preis-Perioden.
Performance
Exclusion Constraints nutzen einen GiST-Index — bei großen Tabellen merklich langsamer beim Schreiben als ein B-tree-Unique-Index, aber meist akzeptabel.
Wer's testen will:
EXPLAIN (ANALYZE, BUFFERS)
INSERT INTO room_bookings (room_id, during)
VALUES (1, '[2026-06-01, 2026-06-02)'::tstzrange);Bei Hot-Path-Inserts (mehrere hundert pro Sekunde) lohnt sich das Messen. Bei normalen Buchungs-Anwendungen kein Thema.
Besonderheiten
EXCLUDE ist Postgres-only — kein SQL-Standard.
Der Constraint-Typ existiert in keinem anderen RDBMS in vergleichbarer Form. Wer cross-DB portabel schreibt, muss die Logik in Application-Code oder Triggern doppelt implementieren — was bekanntlich race-anfällig ist.
btree_gist nicht vergessen für Mixed-Constraints.
Wer room_id = ... AND during && haben will: ohne btree_gist-Extension scheitert der CREATE INDEX. Extension einmalig pro Datenbank: CREATE EXTENSION btree_gist;. Schema-Migrationen daran erinnern.
Range-Inklusivität entscheidet — Standard ist [).
Default-Range-Form ist „untere Grenze inklusiv, obere exklusiv". Heißt: [10:00, 12:00) und [12:00, 14:00) überschneiden sich NICHT. Sehr praktisch — ohne diese Konvention müsste man bei jedem Termin „eine Sekunde abziehen".
EXCLUDE kann mit WHERE-Filter — wie partielle UNIQUE-Indexe.
EXCLUDE USING gist (...) WHERE (status = 'confirmed') macht den Constraint nur für bestimmte Zeilen aktiv. Ähnlich zu Partial Unique Indexes, aber in der EXCLUDE-Syntax direkt eingebaut.
Mehrere EXCLUDE-Constraints pro Tabelle — okay.
Eine Tabelle kann beliebig viele EXCLUDE-Constraints haben (z. B. einer für „Gast nicht doppelt", einer für „Raum nicht doppelt"). Jeder bekommt seinen eigenen GiST-Index.
Alternative: Trigger — aber race-anfällig.
Ohne EXCLUDE müsste man im BEFORE-INSERT-Trigger SELECT … WHERE during && NEW.during prüfen. Zwei parallele Inserts können beide „kein Konflikt" sehen und beide einfügen. EXCLUDE ist atomar; Trigger sind's nicht (außer mit explizitem Locking, das wieder Performance kostet).