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

SQL MERGE als UPSERT-Ersatz
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:

  1. MERGE INTO target AS t — Ziel-Tabelle
  2. USING source AS s ON join-condition — Quelle und Verknüpfung
  3. WHEN MATCHED THEN … — was passiert mit Zeilen, die in beiden vorkommen
  4. WHEN 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

SQL Sync von externer Quelle
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

KlauselWirkung
WHEN MATCHED THEN UPDATE SET …Bei Match: Update
WHEN MATCHED THEN DELETEBei Match: Lösche
WHEN MATCHED THEN DO NOTHINGBei 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:

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

SQL Vollstaendiger Sync inklusive Löschen
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:

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

SituationEmpfehlung
Einfacher Upsert (1 Tabelle, 1 Constraint)ON CONFLICT — knapper
Sync aus Source-Tabelle mit verschiedenen AktionenMERGE — ein Statement statt mehrerer
Insert/Update und Delete im selben SyncMERGE — mit NOT MATCHED BY SOURCE (PG 17+)
Bedingte Aktionen pro Zeile (AND-Filter)MERGE — direkt unterstützt
Multi-Row mit Race-Condition-Schutz auf ConstraintON CONFLICT — atomar pro Zeile
SQL-Standard-Konformität / Cross-DB-PortabilitätMERGE — 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.

SQL Drei Aktionen in einem Statement
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+)

SQL Verfolgen, was MERGE pro Zeile gemacht hat
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.95

merge_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

/ Weiter

Zurück zu SQL-Grundlagen

Zur Übersicht