UPSERT — „insert if new, update if exists” — ist eine der häufigsten Operationen in Web-Anwendungen. PostgreSQL bietet das seit Version 9.5 als INSERT … ON CONFLICT … DO UPDATE. Atomar, race-condition-frei, in einem Statement. Das Stolperpotenzial liegt nicht in der Syntax, sondern in den Constraint-Details.
Grundform
Voraussetzung: in der Tabelle gibt es einen UNIQUE-Constraint auf der Spalte, über die du den Konflikt erkennst. Hier z. B. email UNIQUE in der users-Tabelle. Damit weiß Postgres: wenn jemand einen User mit derselben E-Mail einfügen will, ist das ein Konflikt — und genau dann soll DO UPDATE greifen.
INSERT INTO users (email, name, last_seen_at)
VALUES ('alice@example.com', 'Alice', now())
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name,
last_seen_at = EXCLUDED.last_seen_at;Was hier Schritt für Schritt passiert:
- Postgres versucht den
INSERT. Wennalice@example.comnoch nicht in der Tabelle ist, wird die neue Zeile angelegt — fertig. - Wenn die E-Mail aber bereits existiert, würde der UNIQUE-Constraint normalerweise einen Fehler auslösen. Mit
ON CONFLICT (email)fängt Postgres das ab und führt stattdessen denDO UPDATE-Teil aus. - Im
DO UPDATE-Teil istEXCLUDEDeine Pseudo-Tabelle mit den Werten, die wir gerade einfügen wollten.EXCLUDED.nameist also'Alice',EXCLUDED.last_seen_atist dernow()-Zeitstempel. Damit überschreiben wir die bereits existierenden Felder.
Das Ergebnis: die Zeile existiert garantiert mit den aktuellen Werten — egal, ob sie vorher schon da war oder nicht. „Insert if new, update if exists” — daher der Name Upsert.
DO NOTHING ist die andere Option: Konflikt → leise ignorieren.
INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice')
ON CONFLICT (email) DO NOTHING;Praktisch beim Bulk-Import, wenn man nur neue Zeilen will und Duplikate egal sind.
Constraint-Identifikation
ON CONFLICT (email) adressiert den UNIQUE-Constraint über die Conflict-Target-Spalten. Drei Schreibweisen sind möglich:
-- Variante 1: Spaltenliste (Postgres findet den passenden UNIQUE/PK)
ON CONFLICT (email)
-- Variante 2: explizit per Constraint-Name
ON CONFLICT ON CONSTRAINT users_email_key
-- Variante 3: bei zusammengesetztem Constraint
ON CONFLICT (tenant_id, email)Es muss ein UNIQUE oder PRIMARY KEY-Constraint existieren, der genau diese Spalten umfasst. Ohne Constraint scheitert das Statement mit „there is no unique or exclusion constraint matching the ON CONFLICT specification”.
EXCLUDED — die Pseudo-Tabelle
EXCLUDED.name ist nicht der alte Wert in der Tabelle, sondern der Wert, der gerade eingefügt werden sollte. Praktisch:
INSERT INTO users (email, login_count)
VALUES ('alice@example.com', 1)
ON CONFLICT (email) DO UPDATE
SET login_count = users.login_count + 1;Hier siehst du den Unterschied: users.login_count ist der alte Wert in der Tabelle, EXCLUDED.login_count wäre 1 (was eingefügt werden sollte). Bei einem Login-Counter willst du den alten Wert plus eins — also users.login_count + 1.
Beide sind in DO UPDATE verfügbar.
Mit WHERE — bedingter Update
Der Update-Teil kann eine WHERE-Klausel haben:
INSERT INTO users (email, name, updated_at)
VALUES ('alice@example.com', 'Alice', now())
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name,
updated_at = EXCLUDED.updated_at
WHERE users.updated_at < EXCLUDED.updated_at;Wenn der Insert kommt, der schon „älter” ist als das, was in der Tabelle steht, passiert nichts — Race-Condition-Schutz bei verteilten Schreibern.
Bulk-UPSERT
Der häufigste reale Use-Case: viele Zeilen auf einmal upserten — etwa beim Sync mit einer externen Quelle.
INSERT INTO users (email, name) VALUES
('alice@example.com', 'Alice'),
('bob@example.com', 'Bob'),
('carol@example.com', 'Carol')
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name;Das Statement ist atomar — entweder werden alle Zeilen erfolgreich verarbeitet oder keine. Bei sehr großen Bulk-Operationen (>10.000 Zeilen) lohnen sich aber Chunks: pro Chunk eine Transaktion, sonst werden Locks und WAL zu groß.
Mit RETURNING
Sehr nützlich, um nach einem UPSERT die finale Zeile zurückzubekommen:
INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice')
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name
RETURNING id, email, name,
(xmax = 0) AS was_inserted;xmax = 0 zeigt: war es ein neuer Insert oder ein Update? Ohne separate Query.
DO NOTHING mit RETURNING hat eine Falle: bei einem Konflikt fällt auch das RETURNING aus — du bekommst keine Zeile zurück. Wenn du in dem Fall trotzdem die existierende Zeile brauchst, hilft ein CTE-Pattern:
WITH ins AS (
INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice')
ON CONFLICT (email) DO NOTHING
RETURNING *
)
SELECT * FROM ins
UNION ALL
SELECT * FROM users WHERE email = 'alice@example.com'
AND NOT EXISTS (SELECT 1 FROM ins);Etwas länglich — aber das ist die saubere Variante, wenn man immer eine Zeile zurückhaben will, egal ob sie neu war oder schon existierte.
Praxis-Beispiele
Counter-Pattern: Login-Zähler hochzählen
INSERT INTO user_stats (user_id, login_count, last_login_at)
VALUES ($1, 1, now())
ON CONFLICT (user_id) DO UPDATE
SET login_count = user_stats.login_count + 1,
last_login_at = EXCLUDED.last_login_at;user_stats.login_count + 1 greift den vorhandenen Wert und inkrementiert. EXCLUDED.last_login_at ist der gerade übergebene now()-Zeitstempel. Atomar und race-condition-frei — ohne explizite Transaktion.
Bulk-UPSERT aus externer Quelle
myapp=> INSERT INTO products (sku, name, price, updated_at) VALUES
('A1', 'Widget', 9.95, now()),
('B2', 'Gadget', 19.95, now()),
('C3', 'Gizmo', 29.95, now())
ON CONFLICT (sku) DO UPDATE
SET name = EXCLUDED.name,
price = EXCLUDED.price,
updated_at = EXCLUDED.updated_at
WHERE products.updated_at < EXCLUDED.updated_at
RETURNING sku, (xmax = 0) AS was_inserted;
sku | was_inserted
-----+--------------
A1 | t
B2 | f
C3 | tDrei Ergebnisse: A1 und C3 waren neu (t = inserted), B2 wurde aktualisiert. Das WHERE products.updated_at < EXCLUDED.updated_at schützt davor, dass ein verspäteter Feed einen aktuelleren Datensatz überschreibt.
Race-Condition-Schutz für Initialisierung
INSERT INTO user_settings (user_id, theme, language)
VALUES ($1, 'dark', 'de')
ON CONFLICT (user_id) DO NOTHING
RETURNING id;Klassischer „get-or-create”-Fall, multithread-sicher: zwei parallele Calls können nicht beide die Default-Zeile anlegen. Bei Konflikt liefert RETURNING zwar nichts zurück — aber die Zeile existiert garantiert.
Häufige Stolperfallen
ON CONFLICT (col) braucht einen UNIQUE/PK-Constraint.
Ein Index alleine reicht nicht — es muss ein Constraint sein. CREATE UNIQUE INDEX foo_idx ON users (email) allein klappt; es braucht ein UNIQUE-Constraint, das vom Index gestützt wird. In der Praxis kommt’s meist über CREATE TABLE … UNIQUE, ALTER TABLE … ADD UNIQUE oder einen Primary Key zustande.
EXCLUDED ist nicht die alte Zeile.
Klassischer Anfängerfehler: man möchte den Login-Counter inkrementieren und schreibt SET login_count = EXCLUDED.login_count + 1 — und wundert sich, dass der Counter immer auf 2 stehen bleibt (1 + 1 vom EXCLUDED). Korrekt ist users.login_count + 1. EXCLUDED enthält den Insert-Wert, nicht den Tabellen-Wert.
Mehrere UNIQUE-Constraints — Postgres muss raten.
Wenn die Tabelle mehrere UNIQUE-Constraints hat (z. B. email und external_id getrennt), und du ON CONFLICT ohne Spalten-Target schreibst, lehnt Postgres ab. Immer das Conflict-Target explizit nennen — entweder per Spaltenliste oder Constraint-Name.
Partial Unique Index braucht WHERE im ON CONFLICT.
Wer einen Partial Unique Index hat (UNIQUE INDEX … WHERE deleted_at IS NULL), muss im ON CONFLICT dieselbe WHERE-Klausel wiederholen: ON CONFLICT (email) WHERE deleted_at IS NULL DO …. Sonst findet Postgres den Index nicht und beschwert sich.
DO NOTHING + RETURNING liefert keine Zeile bei Konflikt.
Viele erwarten, bei einem Konflikt die existierende Zeile zurück zu bekommen. Tut RETURNING nicht — bei DO NOTHING bekommst du leeres Resultset. Wer das Verhalten will: CTE-Pattern (oben gezeigt) oder einen separaten SELECT nach dem UPSERT.
UPSERT vergibt eine Sequenz-Zahl, auch wenn sie nicht genutzt wird.
Bei INSERT ... ON CONFLICT DO NOTHING wird die Sequenz (z. B. SERIAL) bereits inkrementiert, BEVOR der Konflikt-Check greift. Wer die ID-Lücken später bemerkt, sucht oft im falschen Code — die Lücken kommen von „verworfenen” Inserts. Das ist normal und sollte kein Problem darstellen.
MERGE (PG 15+) ist eine Alternative.
Seit PG 15 gibt es MERGE, das mehr Flexibilität bietet — z. B. WHEN NOT MATCHED THEN INSERT … WHEN MATCHED THEN UPDATE …. Für einfache Upserts ist ON CONFLICT knapper; für komplexe Source/Target-Joins ist MERGE lesbarer. Eigener Artikel: MERGE-Statement.
Weiterführende Ressourcen
Externe Quellen
- INSERT … ON CONFLICT – PostgreSQL Documentation
- Upsert in PostgreSQL – PostgreSQL Wiki
- MERGE – PostgreSQL Documentation
- Release Notes 9.5 – ON CONFLICT eingeführt