MERGE ist seit PostgreSQL 15 verfügbar — die SQL-Standard-Variante des UPSERT-Patterns. Mehr als ein Synonym zu ON CONFLICT: MERGE verbindet eine Source mit einer Target-Tabelle und reagiert pro Zeile mit INSERT, UPDATE oder DELETE. Für komplexe Synchronisations-Logiken oft lesbarer; für einfache Upserts nicht zwingend nötig.
Grundform
Wir lesen das von oben nach unten: in die Tabelle users (das Target, mit Alias t) wollen wir einen Datensatz „hineinmergen”. Die Quelle (Source, Alias s) ist hier eine Inline-VALUES-Liste mit nur einer Zeile: ('alice@example.com', 'Alice').
MERGE INTO users AS t
USING (VALUES ('alice@example.com', 'Alice')) AS s(email, name)
ON t.email = s.email
WHEN MATCHED THEN
UPDATE SET name = s.name
WHEN NOT MATCHED THEN
INSERT (email, name) VALUES (s.email, s.name);Die Bedingung ON t.email = s.email ist der Vergleich, der entscheidet: existiert die Zeile schon? Wenn ja, greift WHEN MATCHED — wir aktualisieren den Namen. Wenn nein, greift WHEN NOT MATCHED — wir fügen die Zeile neu ein. Effekt: identisch zu einem UPSERT, nur mit anderer Syntax.
Die Struktur ist immer dieselbe:
MERGE INTO target AS t— Ziel-TabelleUSING source AS s ON join-condition— Quelle und VerknüpfungWHEN MATCHED THEN …— was passiert mit Zeilen, die in beiden vorkommenWHEN NOT MATCHED THEN …— was passiert mit Zeilen aus der Source, die im Target fehlen
Die Source kann eine Tabelle, Subquery oder VALUES-Liste sein — alles, was eine Tabellen-Form liefert.
Mit echten Tabellen
MERGE INTO users AS t
USING staging_users AS s
ON t.email = s.email
WHEN MATCHED AND s.deleted THEN
DELETE
WHEN MATCHED THEN
UPDATE SET name = s.name,
last_seen_at = s.last_seen_at
WHEN NOT MATCHED AND s.deleted IS FALSE THEN
INSERT (email, name, last_seen_at)
VALUES (s.email, s.name, s.last_seen_at);Das ist der Use-Case, wo MERGE glänzt: drei verschiedene Aktionen — Insert, Update, Delete — abhängig vom Source-Inhalt. Mit ON CONFLICT müsste man das auf mehrere Statements aufteilen.
WHEN-Klauseln in Detail
| Klausel | Wirkung |
|---|---|
WHEN MATCHED THEN UPDATE SET … | Bei Match: Update |
WHEN MATCHED THEN DELETE | Bei Match: Lösche |
WHEN MATCHED THEN DO NOTHING | Bei Match: ignorieren |
WHEN NOT MATCHED THEN INSERT … | Source-Zeile fehlt im Target → einfügen |
WHEN NOT MATCHED THEN DO NOTHING | … oder ignorieren |
Mit zusätzlichen AND-Bedingungen lassen sich Zeilen feiner kategorisieren:
WHEN MATCHED AND t.status = 'archived' THEN DO NOTHING
WHEN MATCHED THEN UPDATE SET …
WHEN NOT MATCHED THEN INSERT …Postgres prüft die WHEN-Klauseln in der angegebenen Reihenfolge und nimmt die erste, die passt. Logik wie CASE — wer das nicht beachtet, kann subtile Fehler produzieren.
NOT MATCHED BY SOURCE (PG 17+)
Seit Version 17 gibt es zusätzlich WHEN NOT MATCHED BY SOURCE — für Zeilen, die im Target existieren, aber in der Source fehlen:
MERGE INTO users AS t
USING staging_users AS s
ON t.email = s.email
WHEN MATCHED THEN
UPDATE SET name = s.name
WHEN NOT MATCHED BY TARGET THEN
INSERT (email, name) VALUES (s.email, s.name)
WHEN NOT MATCHED BY SOURCE THEN
DELETE;Damit ist MERGE für komplette Tabellen-Synchronisation geeignet — und schlägt das alte Pattern „TRUNCATE + INSERT FROM SOURCE” in puncto Sicherheit (transaktional, behält Foreign Keys, …).
Mit RETURNING (PG 17+)
Seit PG 17 unterstützt MERGE auch eine RETURNING-Klausel — mit merge_action() kann man pro Zeile sehen, was passiert ist:
MERGE INTO users AS t
USING staging_users AS s ON t.email = s.email
WHEN MATCHED THEN UPDATE SET name = s.name
WHEN NOT MATCHED THEN INSERT (email, name) VALUES (s.email, s.name)
RETURNING merge_action(), t.id, t.email;merge_action() liefert pro Zeile 'INSERT', 'UPDATE' oder 'DELETE'.
MERGE vs. ON CONFLICT — wann was?
| Situation | Empfehlung |
|---|---|
| Einfacher Upsert (1 Tabelle, 1 Constraint) | ON CONFLICT — knapper |
| Sync aus Source-Tabelle mit verschiedenen Aktionen | MERGE — ein Statement statt mehrerer |
| Insert/Update und Delete im selben Sync | MERGE — mit NOT MATCHED BY SOURCE (PG 17+) |
Bedingte Aktionen pro Zeile (AND-Filter) | MERGE — direkt unterstützt |
| Multi-Row mit Race-Condition-Schutz auf Constraint | ON CONFLICT — atomar pro Zeile |
| SQL-Standard-Konformität / Cross-DB-Portabilität | MERGE — ist im Standard, ON CONFLICT nicht |
Faustregel: für 80% der Web-App-Use-Cases reicht ON CONFLICT. MERGE lohnt sich, sobald die Logik komplexer wird oder mehrere Aktionen zu kombinieren sind.
Praxis-Beispiele
Inventar-Sync aus CSV-Import
Typischer Use-Case: tägliches Update aus einem Lieferanten-Feed. Neue Produkte einfügen, geänderte aktualisieren, fehlende auf inactive setzen.
MERGE INTO products AS t
USING staging_products AS s
ON t.sku = s.sku
WHEN MATCHED AND s.price IS DISTINCT FROM t.price THEN
UPDATE SET price = s.price,
name = s.name,
updated_at = now(),
status = 'active'
WHEN MATCHED AND s.price = t.price THEN
UPDATE SET name = s.name,
updated_at = now()
WHEN NOT MATCHED THEN
INSERT (sku, name, price, status, created_at)
VALUES (s.sku, s.name, s.price, 'active', now());Drei Verzweigungen: Preis hat sich geändert, Preis identisch (nur Name-Refresh), oder neu im Feed. Mit ON CONFLICT würde man dafür drei separate Statements oder PL/pgSQL brauchen.
Mit RETURNING (PG 17+)
myapp=> MERGE INTO products AS t
USING staging_products AS s ON t.sku = s.sku
WHEN MATCHED THEN UPDATE SET price = s.price
WHEN NOT MATCHED THEN INSERT (sku, name, price)
VALUES (s.sku, s.name, s.price)
RETURNING merge_action(), t.sku, t.price;
merge_action | sku | price
--------------+-----+--------
UPDATE | A1 | 9.95
UPDATE | B2 | 19.95
INSERT | C3 | 29.95
INSERT | D4 | 39.95merge_action() zeigt pro Zeile, was tatsächlich passiert ist. Praktisch für Sync-Reports und Audit-Logs.
Interessantes
MERGE gibt es seit PG 15.
Auf älteren Versionen (PG 14 und davor) ist es nicht verfügbar. Wer auf solchen Setups MERGE-ähnliche Logik braucht: INSERT … ON CONFLICT (für Insert/Update) plus separate DELETE-Statements oder Stored Procedures sind die übliche Krücke.
Reihenfolge der WHEN-Klauseln zählt.
Postgres geht die WHEN-Klauseln pro Source-Zeile von oben nach unten durch und nimmt die erste passende. Wer „spezifischer zuerst, dann allgemeiner” schreibt, liegt richtig — eine WHEN MATCHED THEN UPDATE vor einer WHEN MATCHED AND status='X' THEN … greift immer zuerst und macht die spezifische Klausel toten Code.
MERGE ist atomar pro Statement, nicht pro Zeile.
Wenn eine Zeile einen Constraint-Fehler auslöst, rollt die gesamte Operation zurück. Das unterscheidet MERGE von einem Stored-Procedure-Loop, der einzelne Zeilen weiterlaufen lassen könnte. Für „best-effort”-Sync also nicht ideal — entweder per CTE in kleine Batches splitten oder EXCEPTION-Behandlung in einer PL/pgSQL-Funktion drumrum.
MERGE ist im SQL-Standard, ON CONFLICT nicht.
Wer Cross-DB-Code schreibt, der gegen Postgres und z. B. SQL Server laufen soll, ist mit MERGE portabler. ON CONFLICT gibt es nur in Postgres und SQLite (in leicht anderer Form).
NOT MATCHED BY SOURCE / RETURNING erst ab PG 17.
Beides sind nützliche Erweiterungen, aber: Wer auf PG 15 oder 16 entwickelt, hat sie nicht. Vor dem Einsatz die Postgres-Version prüfen — sonst kommt beim Deploy auf einem älteren Server eine ungemütliche Überraschung.
Weiterführende Ressourcen
Externe Quellen
- MERGE – PostgreSQL Documentation
- Release Notes 15: MERGE eingeführt
- Release Notes 17: NOT MATCHED BY SOURCE, RETURNING
- INSERT … ON CONFLICT – PostgreSQL Documentation