🔀Upserts

Einfügen oder aktualisieren – ON CONFLICT, ON DUPLICATE KEY, MERGE und die REPLACE-Falle.

            INSERT (artikel_nr = 'A-100', preis = 26.50)
                              │
               gibt es den Schlüssel schon?
                 ┌────────────┴────────────┐
                nein                       ja
                 │                          │
             INSERT                 DO UPDATE SET preis = excluded.preis
          (neue Zeile)              ─ oder DO NOTHING ─
                                    excluded = die abgelehnte neue Zeile

Upsert = „update or insert“. Entscheidend ist, dass die Datenbank die Entscheidung atomar trifft – nicht die Anwendung mit einem vorherigen SELECT. Dafür braucht es immer einen PRIMARY KEY oder UNIQUE-Index, an dem der Konflikt erkannt wird.

Die Grundform von INSERT/UPDATE/MERGE erklärt die Schwester-App VisualSql (DDL & DML) – hier geht es um die Praxis: Zähler, Synchronisation, die REPLACE-Falle und Nebenläufigkeit.

🔀 Tipp 1★ EinsteigerON CONFLICTexcludedMERGEON DUPLICATE KEY

Einfügen oder aktualisieren – in einer Anweisung

😖 Problem
Eine Preisliste vom Lieferanten enthält bekannte und neue Artikel. Mit SELECT prüfen und danach INSERT oder UPDATE schicken ist umständlich – und bei gleichzeitigen Zugriffen falsch.
💡 Lösung
Ein Upsert fügt ein und weicht bei einem Schlüsselkonflikt auf ein UPDATE aus. Die abgelehnte neue Zeile heißt excluded – so übernimmst du gezielt einzelne Spalten und lässt andere (hier bestand) in Ruhe.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQLab 9.5
▶ ausführbar · PGlite
INSERT INTO artikel (artikel_nr, bezeichnung, kategorie, preis, bestand)
VALUES ('A-100', 'Teekanne Glas', 'Küche', 26.50, 0)
ON CONFLICT (artikel_nr) DO UPDATE
  SET preis = EXCLUDED.preis;

Ab PostgreSQL 15 alternativ MERGE. Der Trick xmax <> 0 in RETURNING (zeigt Update statt Insert) ist ein Implementierungsdetail, keine dokumentierte Schnittstelle.

MySQL / MariaDBab MySQL 8.0.19
nur gezeigt, nicht ausgeführt
INSERT INTO artikel (artikel_nr, bezeichnung, kategorie, preis, bestand)
VALUES ('A-100', 'Teekanne Glas', 'Küche', 26.50, 0) AS neu
ON DUPLICATE KEY UPDATE preis = neu.preis;

-- MariaDB und MySQL < 8.0.19:
--   … ON DUPLICATE KEY UPDATE preis = VALUES(preis);
-- VALUES() ist in MySQL seit 8.0.20 als veraltet markiert.

Rückgabe „affected rows“: 1 = eingefügt, 2 = aktualisiert, 0 = unverändert. Greift bei jedem PRIMARY KEY oder UNIQUE-Index – ein Konfliktziel wie ON CONFLICT (…) gibt es nicht.

SQL Serverab 2008
nur gezeigt, nicht ausgeführt
MERGE INTO artikel WITH (HOLDLOCK) AS z
USING (VALUES ('A-100', 'Teekanne Glas', 'Küche', 26.50, 0))
      AS q (artikel_nr, bezeichnung, kategorie, preis, bestand)
   ON z.artikel_nr = q.artikel_nr
WHEN MATCHED THEN
  UPDATE SET preis = q.preis
WHEN NOT MATCHED THEN
  INSERT (artikel_nr, bezeichnung, kategorie, preis, bestand)
  VALUES (q.artikel_nr, q.bezeichnung, q.kategorie, q.preis, q.bestand);

MERGE muss mit Semikolon enden. HOLDLOCK (= SERIALIZABLE) verhindert, dass zwei Sitzungen gleichzeitig „NOT MATCHED“ sehen.

Oracleab 9i
nur gezeigt, nicht ausgeführt
MERGE INTO artikel z
USING (SELECT 'A-100' AS artikel_nr, 'Teekanne Glas' AS bezeichnung,
              'Küche' AS kategorie, 26.50 AS preis, 0 AS bestand
         FROM dual) q
   ON (z.artikel_nr = q.artikel_nr)
WHEN MATCHED THEN
  UPDATE SET z.preis = q.preis
WHEN NOT MATCHED THEN
  INSERT (artikel_nr, bezeichnung, kategorie, preis, bestand)
  VALUES (q.artikel_nr, q.bezeichnung, q.kategorie, q.preis, q.bestand);

Die ON-Bedingung steht in Klammern; Spalten aus der ON-Bedingung dürfen im UPDATE nicht geändert werden.

SQLiteab 3.24.0
▶ ausgeführt · sql.js
INSERT INTO artikel (artikel_nr, bezeichnung, kategorie, preis, bestand)
VALUES ('A-100', 'Teekanne Glas', 'Küche', 26.50, 0)
ON CONFLICT (artikel_nr) DO UPDATE
  SET preis = excluded.preis;

Syntax nach dem Vorbild von PostgreSQL. Mehrere ON CONFLICT-Klauseln sind ab 3.35.0 erlaubt.

⚠️ Fallstricke

  • Das Konfliktziel ON CONFLICT (spalten) braucht einen passenden PRIMARY KEY oder UNIQUE-Index – sonst Fehler.
  • excluded enthält die vorgeschlagene Zeile inklusive Default-Werten. SET bestand = excluded.bestand würde den Lagerbestand hier auf 0 setzen.
  • Unnötige Schreibvorgänge vermeiden: DO UPDATE SET … WHERE artikel.preis <> excluded.preis – dann feuern UPDATE-Trigger nur bei echten Änderungen.
  • PostgreSQL erlaubt nicht, dass eine Anweisung dieselbe Zeile zweimal trifft (zweimal A-100 in VALUES) – laut Doku ein Cardinality-Violation-Fehler („… cannot affect row a second time“). Quelldaten vorher entdoppeln.

📚 Belege

🔀 Tipp 2★ EinsteigerON CONFLICTZählerLost Update

Zähler atomar hochzählen

😖 Problem
Seitenaufrufe sollen pro Seite gezählt werden. „Wert lesen, +1 rechnen, zurückschreiben“ in der Anwendung verliert bei gleichzeitigen Aufrufen Zählungen (Lost Update).
💡 Lösung
Der Upsert rechnet in der Datenbank: Existiert die Seite, wird der alte Wert plus der neue addiert, sonst beginnt der Zähler bei 1. Eine Anweisung, keine Lücke dazwischen.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQLab 9.5
▶ ausführbar · PGlite
INSERT INTO seitenaufrufe (seite, aufrufe, zuletzt)
VALUES ('/start', 1, CURRENT_DATE)
ON CONFLICT (seite) DO UPDATE
  SET aufrufe = seitenaufrufe.aufrufe + EXCLUDED.aufrufe,
      zuletzt = EXCLUDED.zuletzt;
MySQL / MariaDBab MySQL 8.0.19
nur gezeigt, nicht ausgeführt
INSERT INTO seitenaufrufe (seite, aufrufe, zuletzt)
VALUES ('/start', 1, CURRENT_DATE) AS neu
ON DUPLICATE KEY UPDATE
  aufrufe = aufrufe + neu.aufrufe,
  zuletzt = neu.zuletzt;

Links vom = und unqualifiziert rechts ist die vorhandene Zeile gemeint.

SQL Serverab 2008
nur gezeigt, nicht ausgeführt
MERGE INTO seitenaufrufe WITH (HOLDLOCK) AS z
USING (VALUES ('/start', 1, CAST(GETDATE() AS date))) AS q (seite, aufrufe, zuletzt)
   ON z.seite = q.seite
WHEN MATCHED THEN
  UPDATE SET aufrufe = z.aufrufe + q.aufrufe, zuletzt = q.zuletzt
WHEN NOT MATCHED THEN
  INSERT (seite, aufrufe, zuletzt) VALUES (q.seite, q.aufrufe, q.zuletzt);
Oracleab 9i
nur gezeigt, nicht ausgeführt
MERGE INTO seitenaufrufe z
USING (SELECT '/start' AS seite, 1 AS aufrufe, TRUNC(SYSDATE) AS zuletzt FROM dual) q
   ON (z.seite = q.seite)
WHEN MATCHED THEN
  UPDATE SET z.aufrufe = z.aufrufe + q.aufrufe, z.zuletzt = q.zuletzt
WHEN NOT MATCHED THEN
  INSERT (seite, aufrufe, zuletzt) VALUES (q.seite, q.aufrufe, q.zuletzt);
SQLiteab 3.24.0
▶ ausgeführt · sql.js
INSERT INTO seitenaufrufe (seite, aufrufe, zuletzt)
VALUES ('/start', 1, date('now'))
ON CONFLICT (seite) DO UPDATE
  SET aufrufe = seitenaufrufe.aufrufe + excluded.aufrufe,
      zuletzt = excluded.zuletzt;

⚠️ Fallstricke

  • Bei sehr heißen Zählern (tausende Schreibzugriffe pro Sekunde auf eine Zeile) wird die Zeilensperre zum Flaschenhals – dann besser Ereignisse einfügen und periodisch aggregieren.
  • Den alten Wert immer qualifizieren (seitenaufrufe.aufrufe), damit klar ist, dass nicht excluded.aufrufe gemeint ist.

📚 Belege

🔀 Tipp 3★ EinsteigerDO NOTHINGRETURNINGINSERT IGNORE

Nur einfügen, wenn neu – und sehen, was wirklich eingefügt wurde

😖 Problem
Newsletter-Anmeldungen kommen mehrfach. Vorhandene Adressen sollen still übersprungen werden, ohne dass der Import abbricht – aber man will wissen, welche Adressen tatsächlich neu waren.
💡 Lösung
ON CONFLICT DO NOTHING überspringt Konflikte. Mit RETURNING liefert die Anweisung genau die eingefügten Zeilen zurück – übersprungene erscheinen nicht.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQLab 9.5
▶ ausführbar · PGlite
INSERT INTO abos (email, seit)
VALUES ('theo.brandt@example.org', CURRENT_DATE)
ON CONFLICT (email) DO NOTHING
RETURNING email;
MySQL / MariaDB
nur gezeigt, nicht ausgeführt
-- Sauber: No-Op-Update nur für den Schlüsselkonflikt
INSERT INTO abos (email, seit)
VALUES ('theo.brandt@example.org', CURRENT_DATE)
ON DUPLICATE KEY UPDATE email = email;

-- Kürzer, aber gefährlich: INSERT IGNORE macht auch
-- andere Fehler (z. B. ungültige Werte) zu Warnungen.

RETURNING gibt es in MySQL nicht; MariaDB kennt INSERT … RETURNING ab 10.5.

SQL Serverab 2008
nur gezeigt, nicht ausgeführt
MERGE INTO abos WITH (HOLDLOCK) AS z
USING (VALUES ('theo.brandt@example.org', CAST(GETDATE() AS date))) AS q (email, seit)
   ON z.email = q.email
WHEN NOT MATCHED THEN
  INSERT (email, seit) VALUES (q.email, q.seit)
OUTPUT inserted.email;
Oracleab 9i
nur gezeigt, nicht ausgeführt
MERGE INTO abos z
USING (SELECT 'theo.brandt@example.org' AS email, TRUNC(SYSDATE) AS seit FROM dual) q
   ON (z.email = q.email)
WHEN NOT MATCHED THEN
  INSERT (email, seit) VALUES (q.email, q.seit);

Beide WHEN-Zweige sind optional – nur WHEN NOT MATCHED ergibt „einfügen, wenn neu“.

SQLiteab 3.35.0 (RETURNING)
▶ ausgeführt · sql.js
INSERT INTO abos (email, seit)
VALUES ('theo.brandt@example.org', date('now'))
ON CONFLICT (email) DO NOTHING
RETURNING email;

ON CONFLICT DO NOTHING ab 3.24.0, RETURNING ab 3.35.0. Alternativ: INSERT OR IGNORE (ignoriert auch NOT NULL- und CHECK-Verletzungen dieser Zeile).

⚠️ Fallstricke

  • DO NOTHING ohne Konfliktziel überspringt jeden Eindeutigkeitskonflikt – auch den auf einem anderen UNIQUE-Index, den du vielleicht sehen wolltest.
  • INSERT IGNORE (MySQL) und INSERT OR IGNORE (SQLite) verschlucken mehr als nur Duplikate – für Datenqualität lieber gezielt auf den Schlüssel reagieren.

📚 Belege

🔀 Tipp 4★★ FortgeschrittenMERGEINSERT … SELECTSynchronisation

Tabellen abgleichen: Lieferung in den Bestand buchen

😖 Problem
Eine Lieferung (Staging-Tabelle) soll in artikel gebucht werden: vorhandene Artikel bekommen mehr Bestand und den neuen Preis, unbekannte werden angelegt.
💡 Lösung
MERGE verbindet Quelle und Ziel über eine Bedingung und entscheidet je Zeile: WHEN MATCHED → UPDATE, WHEN NOT MATCHED → INSERT. SQLite hat kein MERGE – dort erledigt INSERT … SELECT … ON CONFLICT dasselbe.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQLab 15 (RETURNING: 17)
▶ ausführbar · PGlite
MERGE INTO artikel a
USING lieferung l ON a.artikel_nr = l.artikel_nr
WHEN MATCHED THEN
  UPDATE SET bestand = a.bestand + l.menge, preis = l.preis
WHEN NOT MATCHED THEN
  INSERT (artikel_nr, bezeichnung, kategorie, preis, bestand)
  VALUES (l.artikel_nr, l.bezeichnung, l.kategorie, l.preis, l.menge)
RETURNING merge_action(), a.*;

Ab 17 zusätzlich WHEN NOT MATCHED BY SOURCE (Zielzeilen ohne Quelle, z. B. löschen).

MySQL / MariaDBfehlt – Ersatzweg
nur gezeigt, nicht ausgeführt
-- kein MERGE: INSERT … SELECT mit ON DUPLICATE KEY UPDATE
INSERT INTO artikel (artikel_nr, bezeichnung, kategorie, preis, bestand)
SELECT * FROM (
  SELECT artikel_nr, bezeichnung, kategorie, preis, menge FROM lieferung
) AS neu
ON DUPLICATE KEY UPDATE
  bestand = artikel.bestand + neu.menge,
  preis   = neu.preis;

MySQL und MariaDB haben kein MERGE. Die abgeleitete Tabelle „neu“ macht die Quellspalten im UPDATE-Teil ansprechbar.

SQL Serverab 2008
nur gezeigt, nicht ausgeführt
MERGE INTO artikel WITH (HOLDLOCK) AS a
USING lieferung AS l
   ON a.artikel_nr = l.artikel_nr
WHEN MATCHED THEN
  UPDATE SET bestand = a.bestand + l.menge, preis = l.preis
WHEN NOT MATCHED BY TARGET THEN
  INSERT (artikel_nr, bezeichnung, kategorie, preis, bestand)
  VALUES (l.artikel_nr, l.bezeichnung, l.kategorie, l.preis, l.menge)
OUTPUT $action, inserted.artikel_nr, inserted.bestand;
Oracleab 9i
nur gezeigt, nicht ausgeführt
MERGE INTO artikel a
USING lieferung l
   ON (a.artikel_nr = l.artikel_nr)
WHEN MATCHED THEN
  UPDATE SET a.bestand = a.bestand + l.menge, a.preis = l.preis
WHEN NOT MATCHED THEN
  INSERT (artikel_nr, bezeichnung, kategorie, preis, bestand)
  VALUES (l.artikel_nr, l.bezeichnung, l.kategorie, l.preis, l.menge);

Löschen im Ziel geht in Oracle über DELETE WHERE … im WHEN-MATCHED-Zweig.

SQLiteab 3.24.0fehlt – Ersatzweg
▶ ausgeführt · sql.js
INSERT INTO artikel (artikel_nr, bezeichnung, kategorie, preis, bestand)
SELECT artikel_nr, bezeichnung, kategorie, preis, menge
  FROM lieferung WHERE true
ON CONFLICT (artikel_nr) DO UPDATE
  SET bestand = artikel.bestand + excluded.bestand,
      preis   = excluded.preis;

Kein MERGE. Das WHERE true löst eine Mehrdeutigkeit des Parsers auf (ON aus JOIN oder ON CONFLICT?).

⚠️ Fallstricke

  • SQLite: Laut Doku sollte das SELECT in INSERT … SELECT … ON CONFLICT immer eine WHERE-Klausel haben (notfalls WHERE true) – sonst kann der Parser ON als Join-Bedingung lesen. In sql.js 3.49.1 führt das Weglassen hier zu einem Syntaxfehler.
  • Doppelte Schlüssel in der Quelle sind ein Fehler: SQL Server und Oracle brechen ab, wenn eine Zielzeile mehrfach getroffen würde. Quelle vorher mit GROUP BY verdichten.
  • MERGE ist nicht automatisch nebenläufigkeitssicher: In SQL Server empfiehlt man HOLDLOCK; in PostgreSQL kann MERGE bei gleichzeitigen Inserts mit einem Unique-Fehler enden – dort ist INSERT … ON CONFLICT die robuste Wahl.

📚 Belege

🔀 Tipp 5★★ FortgeschrittenREPLACEDELETE + INSERTFremdschlüsselTrigger

INSERT OR REPLACE ist kein Upsert

😖 Problem
INSERT OR REPLACE (SQLite) bzw. REPLACE INTO (MySQL) sieht aus wie ein Upsert. Tatsächlich wird die kollidierende Zeile gelöscht und eine neue eingefügt.
💡 Lösung
Das hat Folgen: eine neue Autoincrement-ID, nicht angegebene Spalten fallen auf den Default zurück, ON DELETE CASCADE löscht abhängige Zeilen – und DELETE-Trigger feuern in SQLite nur mit PRAGMA recursive_triggers. Führe es aus und vergleiche mit dem echten Upsert.

🧪 Ausprobieren – Vorher / Nachher

PostgreSQL-Fassung nur statisch (unten)jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQLfehlt – Ersatzweg
nur gezeigt, nicht ausgeführt
-- kein REPLACE – echter Upsert:
INSERT INTO konten (email, name)
VALUES ('anna.berger@example.org', 'Anna Berger-Lind')
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name;
MySQL / MariaDB
nur gezeigt, nicht ausgeführt
-- Falle: löscht und fügt neu ein
REPLACE INTO konten (email, name)
VALUES ('anna.berger@example.org', 'Anna Berger-Lind');

-- besser:
INSERT INTO konten (email, name)
VALUES ('anna.berger@example.org', 'Anna Berger-Lind') AS neu
ON DUPLICATE KEY UPDATE name = neu.name;

Laut MySQL-Doku löst REPLACE bei einem Konflikt DELETE- und INSERT-Trigger aus; affected rows zählt beide Vorgänge.

SQL Serverfehlt – Ersatzweg
nur gezeigt, nicht ausgeführt
-- kein REPLACE – MERGE oder UPDATE/INSERT in einer Transaktion
MERGE INTO konten WITH (HOLDLOCK) AS z
USING (VALUES ('anna.berger@example.org', 'Anna Berger-Lind')) AS q (email, name)
   ON z.email = q.email
WHEN MATCHED THEN UPDATE SET name = q.name
WHEN NOT MATCHED THEN INSERT (email, name) VALUES (q.email, q.name);
Oraclefehlt – Ersatzweg
nur gezeigt, nicht ausgeführt
-- kein REPLACE – MERGE
MERGE INTO konten z
USING (SELECT 'anna.berger@example.org' AS email, 'Anna Berger-Lind' AS name FROM dual) q
   ON (z.email = q.email)
WHEN MATCHED THEN UPDATE SET z.name = q.name
WHEN NOT MATCHED THEN INSERT (email, name) VALUES (q.email, q.name);
SQLite
▶ ausgeführt · sql.js
-- Falle:
INSERT OR REPLACE INTO konten (email, name) VALUES (…);
-- (REPLACE INTO … ist ein Alias dafür)

-- richtig:
INSERT INTO konten (email, name) VALUES (…)
ON CONFLICT (email) DO UPDATE SET name = excluded.name;

Die Konfliktauflösung REPLACE löscht die störenden Zeilen; DELETE-Trigger nur mit PRAGMA recursive_triggers = ON.

⚠️ Fallstricke

  • Neue ID: Die Zeile bekommt einen neuen Autoincrement-Wert – Verweise aus anderen Systemen (Caches, URLs, Exporte) zeigen ins Leere.
  • Datenverlust: Spalten, die im REPLACE nicht vorkommen (hier punkte), fallen auf den Default zurück.
  • Kaskaden: Im Beispiel löscht ON DELETE CASCADE die Notizen still mit – beobachtet in sql.js (SQLite 3.49.1); die SQLite-Doku beschreibt diesen Fall nicht ausdrücklich, also nie darauf verlassen, dass abhängige Daten überleben.
  • Trigger: In SQLite feuert der DELETE-Trigger nur mit PRAGMA recursive_triggers = ON; in MySQL feuern DELETE- und INSERT-Trigger. Audit-Logs sehen so „Löschen + Neuanlage“ statt „Änderung“.

📚 Belege

🔀 Tipp 6★★★ ProfiNebenläufigkeitUNIQUECheck-then-Act

Race Condition: „erst prüfen, dann einfügen“

😖 Problem
Die Anwendung prüft mit SELECT, ob eine E-Mail schon existiert, und fügt sie sonst ein. Kommen zwei Anfragen gleichzeitig, sehen beide „gibt es noch nicht“ – und beide fügen ein.
💡 Lösung
Die Prüfung gehört in die Datenbank: Ein UNIQUE-Constraint macht das zweite INSERT zum Fehler, ein Upsert macht daraus eine atomare Anweisung. Die Zeitachse unten spielt beide Sitzungen Schritt für Schritt durch – die Anweisungen laufen dabei wirklich in SQLite, in genau dieser Reihenfolge.

🧪 Ausprobieren – Vorher / Nachher

PostgreSQL-Fassung nur statisch (unten)jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQLab 9.5
nur gezeigt, nicht ausgeführt
INSERT INTO anmeldungen (email, anzahl) VALUES ($1, 1)
ON CONFLICT (email) DO UPDATE SET anzahl = anmeldungen.anzahl + 1;

Laut Doku garantiert ON CONFLICT DO UPDATE ein atomares Ergebnis INSERT oder UPDATE – auch unter hoher Nebenläufigkeit (READ COMMITTED).

MySQL / MariaDBab MySQL 8.0.19
nur gezeigt, nicht ausgeführt
INSERT INTO anmeldungen (email, anzahl) VALUES (?, 1) AS neu
ON DUPLICATE KEY UPDATE anzahl = anzahl + 1;
SQL Serverab 2008
nur gezeigt, nicht ausgeführt
MERGE INTO anmeldungen WITH (HOLDLOCK) AS z
USING (VALUES (@email)) AS q (email) ON z.email = q.email
WHEN MATCHED THEN UPDATE SET anzahl = z.anzahl + 1
WHEN NOT MATCHED THEN INSERT (email, anzahl) VALUES (q.email, 1);

Ohne HOLDLOCK/SERIALIZABLE können zwei MERGE gleichzeitig „NOT MATCHED“ sehen – dann schlägt eins am PRIMARY KEY fehl.

Oracleab 9i
nur gezeigt, nicht ausgeführt
MERGE INTO anmeldungen z
USING (SELECT :email AS email FROM dual) q ON (z.email = q.email)
WHEN MATCHED THEN UPDATE SET z.anzahl = z.anzahl + 1
WHEN NOT MATCHED THEN INSERT (email, anzahl) VALUES (q.email, 1);

Auch hier kann das parallele INSERT mit ORA-00001 (unique constraint) scheitern – Anwendung sollte dann einmal wiederholen.

SQLiteab 3.24.0
▶ ausgeführt · sql.js
INSERT INTO anmeldungen (email, anzahl) VALUES (?, 1)
ON CONFLICT (email) DO UPDATE SET anzahl = anmeldungen.anzahl + 1;

SQLite serialisiert Schreibzugriffe über eine Datenbanksperre – der zweite Schreiber wartet (busy_timeout) oder bekommt SQLITE_BUSY.

⚠️ Fallstricke

  • Ohne UNIQUE-Constraint hilft auch eine Transaktion nicht: Unter READ COMMITTED sehen beide Sitzungen die fehlende Zeile, keine Sperre verhindert das zweite INSERT.
  • Mit UNIQUE, aber ohne Upsert, muss die Anwendung den Duplikatfehler abfangen und sinnvoll reagieren (erneut lesen, als Erfolg werten).
  • SELECT … FOR UPDATE sperrt nur vorhandene Zeilen – eine noch nicht existierende Zeile lässt sich so nicht reservieren.

📚 Belege

Grundlagen in der Schwester-App
↗ VisualSql: Transaktionen & Isolationslevel