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

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

SQL Mehrere NULLs sind erlaubt
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.

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

Vor 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

SQL Eindeutigkeit über Spalten-Kombination
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.

SQL Nur eine aktive Subscription pro Nutzer
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.

SQL Mit Expression-Index
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-ConstraintUnique-Index
Auflisten in \dJa, als ConstraintJa, als Index
Von FKs referenzierbarJaJa
Partial möglichNeinJa (mit WHERE)
Expression möglichNeinJa
Kann CONCURRENTLY erstellt werdenNicht direktJa

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:

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

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Constraints & Schema-Design

Zur Übersicht