Eine UNIQUE-Constraint garantiert, dass kein Wert (oder keine Wert-Kombination) doppelt vorkommt. Postgres hat dabei eine SQL-Standard-Eigenheit: NULLs gelten als unterschiedlich — mehrere NULLs sind erlaubt, auch in einer UNIQUE-Spalte. Ab PG 15 änderbar mit NULLS NOT DISTINCT. Hier alle Varianten.
Single-Column-UNIQUE
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
name text NOT NULL
);UNIQUE legt automatisch einen Unique-B-tree-Index auf der Spalte an. Damit:
- Schnelles Look-up bei Filtern auf
email - Eindeutigkeit wird zur Schreib-Zeit erzwungen
Anders als PRIMARY KEY: UNIQUE allein erlaubt NULLs, und du kannst beliebig viele UNIQUE-Constraints pro Tabelle haben.
NULL-Verhalten — die SQL-Standard-Eigenheit
Im SQL-Standard und auch in Postgres: zwei NULLs gelten als unterschiedlich. Heißt:
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text UNIQUE, -- NULL-fähig
phone text UNIQUE -- NULL-fähig
);
INSERT INTO users (email, phone) VALUES (NULL, NULL); -- ok
INSERT INTO users (email, phone) VALUES (NULL, NULL); -- ok!
INSERT INTO users (email, phone) VALUES (NULL, NULL); -- auch ok!Drei Zeilen mit jeweils NULL — Postgres beschwert sich nicht, weil zwei NULLs nicht „gleich" sind in der SQL-Logik.
Pragmatisch oft das gewünschte Verhalten (z. B. wenn email optional ist und mehrere Nutzer ohne Email registriert sein dürfen). Manchmal nicht.
NULLS NOT DISTINCT ab PG 15
Seit PostgreSQL 15: das alte Verhalten umkehren — zwei NULLs werden als gleich behandelt.
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text,
UNIQUE NULLS NOT DISTINCT (email)
);
INSERT INTO users (email) VALUES (NULL); -- ok
INSERT INTO users (email) VALUES (NULL); -- ERROR: duplicate keyVor PG 15 war der Workaround entweder:
email NOT NULL(geht nur, wenn jeder einen Wert hat)- ein partieller Unique-Index mit Filter auf den Sonderwert
Heute viel sauberer mit NULLS NOT DISTINCT.
Composite UNIQUE — über mehrere Spalten
CREATE TABLE memberships (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
group_id bigint NOT NULL REFERENCES groups(id),
joined_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, group_id)
);Ein User darf in vielen Gruppen sein, eine Gruppe viele Mitglieder haben — aber das Paar (user, group) nur einmal vorkommen.
Im Vergleich zum Composite-Primary-Key: hier hast du zusätzlich einen Surrogate-PK (id) für saubere FK-Referenzen aus anderen Tabellen.
Partial Unique Index — Eindeutigkeit nur unter Bedingung
UNIQUE kann keine Bedingung haben. Wer das braucht: Unique-Index mit WHERE.
CREATE TABLE subscriptions (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
status text NOT NULL,
...
);
CREATE UNIQUE INDEX subscriptions_one_active_per_user
ON subscriptions (user_id)
WHERE status = 'active';Ein User darf beliebig viele Subscriptions in History haben, aber nur eine 'active'. Klassisches Pattern für Soft-State-Eindeutigkeit. Mehr im Artikel Partial Index.
Case-Insensitive Eindeutigkeit
Email-Adressen Hans@example.com und hans@example.com sind in der Datenbank standardmäßig unterschiedlich — was meist nicht gewollt ist.
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL
);
CREATE UNIQUE INDEX users_email_lower_idx
ON users (lower(email));Damit fallen Hans@example.com und hans@example.com auf den selben Index-Eintrag — die zweite Insertion scheitert.
Alternative: die citext-Extension (case-insensitive text type). In PG 18 nicht mehr in Core empfohlen — lower()-Indexe sind der pragmatische Weg.
UNIQUE oder Unique-Index?
UNIQUE als Constraint ist syntaktischer Zucker — Postgres legt darunter einen Unique-Index an.
UNIQUE-Constraint | Unique-Index | |
|---|---|---|
Auflisten in \d | Ja, als Constraint | Ja, als Index |
| Von FKs referenzierbar | Ja | Ja |
| Partial möglich | Nein | Ja (mit WHERE) |
| Expression möglich | Nein | Ja |
Kann CONCURRENTLY erstellt werden | Nicht direkt | Ja |
Pragmatisch:
- für einfache Spalten-Eindeutigkeit:
UNIQUE-Constraint (aussagekräftig im Schema) - für bedingte oder Expression-basierte: Unique-Index
Zusammenspiel mit ON CONFLICT
UNIQUE ist die Voraussetzung für ON CONFLICT-UPSERTs:
INSERT INTO products (sku, name, price)
VALUES ('SKU-100', 'Widget', 29.99)
ON CONFLICT (sku) -- benötigt UNIQUE auf sku
DO UPDATE SET price = EXCLUDED.price;Ohne UNIQUE (oder PK) auf der Conflict-Spalte schlägt das Statement fehl. Mehr im Artikel UPSERT mit ON CONFLICT.
FAQ
Warum sind mehrere NULLs in einer UNIQUE-Spalte erlaubt?
SQL-Standard: NULL = unbekannter Wert, daher gilt NULL = NULL als unbekannt (nicht TRUE). Postgres folgt dem Standard. Für die andere Variante: UNIQUE NULLS NOT DISTINCT ab PG 15, oder partieller Unique-Index WHERE col IS NOT NULL.
Was ist der Unterschied zwischen UNIQUE und PRIMARY KEY?
PRIMARY KEY = UNIQUE + NOT NULL + max. einer pro Tabelle + Default-Ziel für FKs. UNIQUE ist die schwächere Variante: NULL erlaubt, beliebig viele pro Tabelle. Funktional sind beide ähnlich (Index, Eindeutigkeit).
Kann ich eine UNIQUE-Constraint auf eine Berechnung legen?
Direkt nicht — UNIQUE (lower(email)) geht nicht als Constraint. Aber als Unique-Index: CREATE UNIQUE INDEX … ON tabelle (lower(email)). Funktional identisch. Mehr im Artikel Expression-Index.
Sind UNIQUE-Constraints schneller als manuelle Prüfung?
Klar ja. UNIQUE nutzt einen B-tree-Index, Look-up ist O(log n). Manuelle Prüfung in Application-Code („gibt's das schon?") ist Race-anfällig (zwei Threads sehen gleichzeitig „nein") und braucht trotzdem den Index. Constraint ist atomar und sicher.
Wie ändere ich eine bestehende UNIQUE-Constraint?
ALTER TABLE … DROP CONSTRAINT name, ADD CONSTRAINT name UNIQUE (...) — aber zwischen DROP und ADD könnten Duplikate eingeschoben werden. Sicherer Pfad: CREATE UNIQUE INDEX CONCURRENTLY der neuen Variante, dann Constraint daraus erzeugen, dann alten Index droppen.
Welcher Index-Typ wird angelegt?
Immer B-tree. Andere Index-Typen (Hash, GIN) unterstützen nicht alle die UNIQUE-Eigenschaft. Wer auf JSON-Feldern Eindeutigkeit braucht: Expression-Index mit B-tree auf dem extrahierten Wert (UNIQUE INDEX ON t ((data->>'email'))).