| name | sql-merge-and-upsert |
| description | Guides atomic upsert — never a race-prone "SELECT then INSERT-or-UPDATE" in application code. Teaches the standard `MERGE` statement (`USING ... ON ...` with `WHEN MATCHED THEN UPDATE/DELETE` and `WHEN NOT MATCHED THEN INSERT`) and maps the real-world alternatives — `INSERT ... ON CONFLICT (col) DO UPDATE/DO NOTHING` in PostgreSQL/SQLite (with `excluded.col`) and `INSERT ... ON DUPLICATE KEY UPDATE` in MySQL/MariaDB (with the `new.col` row alias) — since `MERGE` is unevenly adopted (PostgreSQL only since v15; absent in MySQL and SQLite). Establishes that every upsert REQUIRES a UNIQUE or PRIMARY KEY constraint to define the "conflict," and flags the `MERGE` concurrency footgun — it can still raise a unique-violation under concurrent inserts and is not a substitute for a unique constraint plus retry. Auto-invokes when writing or editing `MERGE`, `INSERT ... ON CONFLICT` / `ON DUPLICATE KEY UPDATE`, any upsert / "insert-or-update" / "create-or-update" logic, or check-then-act read-modify-write code that selects a row and then decides to insert or update. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
SQL Merge and Upsert
"An UPSERT is an ordinary INSERT statement that is followed by one or more ON CONFLICT clauses."
— SQLite — UPSERT
"You may also wish to consider using INSERT ... ON CONFLICT as an alternative statement which offers the ability to run an UPDATE if a concurrent INSERT occurs. ... If a target row is modified more than once, a uniqueness violation or cardinality violation will occur."
— PostgreSQL — MERGE, Notes
"Upsert" is "a portmanteau of update and insert" — a statement that "inserts a record ... if the record does not exist or, if the record already exists, updates the existing record" (Wikipedia — Merge (SQL)). The hard part is not the spelling; it is doing it atomically, in one statement the database serializes — instead of the check-then-act pattern most application code reaches for first. This skill assumes the set semantics and three-valued logic of sql-relational-and-null-discipline and the constraint machinery of sql-constraints-and-integrity: an upsert is only meaningful against a UNIQUE/PRIMARY KEY constraint, so those two foundations decide everything below.
1. The Check-Then-Act Race — the centerpiece
The instinct is to SELECT, branch in the application, then INSERT or UPDATE. That is a read-modify-write across a gap: between the SELECT and the write, another session can change the world. It is the classic check-then-act concurrency bug.
row = db.execute("SELECT 1 FROM users WHERE email = ?", [email]).fetchone()
if row is None:
db.execute("INSERT INTO users (email, name) VALUES (?, ?)", [email, name])
else:
db.execute("UPDATE users SET name = ? WHERE email = ?", [name, email])
Under load this fails two ways. If email is UNIQUE, both sessions see "absent," both INSERT, and the second INSERT raises a duplicate-key error (a crash). If it is not unique, you get two duplicate rows, or — when both take the UPDATE branch — a lost update, where one session's write silently overwrites the other's. No amount of SELECT retry closes the gap; the check and the act are separate statements.
The fix is a single atomic statement. The engine evaluates the conflict and the write together, serializing concurrent writers on the unique index:
INSERT INTO users (email, name) VALUES (:email, :name)
ON CONFLICT (email) DO UPDATE SET name = excluded.name;
Why this is safe (and the precise isolation-level / MVCC behavior) is owned by sql-transactions-and-isolation; this skill's rule is simply: never branch in application code between a read and the write it depends on — express insert-or-update as one statement.
2. Standard MERGE — Syntax and Arms
MERGE was "officially introduced in the SQL:2003 standard, and expanded in the SQL:2008 standard" (Wikipedia — Merge (SQL)). It joins a target table to a data_source on a condition, then applies arms by match status:
MERGE INTO target_table_name [ AS target_alias ]
USING data_source ON join_condition
WHEN MATCHED [ AND condition ] THEN { UPDATE SET ... | DELETE | DO NOTHING }
WHEN NOT MATCHED [ AND condition ] THEN { INSERT (...) VALUES (...) | DO NOTHING }
That arm grammar is from the PostgreSQL — MERGE Synopsis. A worked upsert:
MERGE INTO inventory AS t
USING (VALUES (:sku, :qty)) AS s(sku, qty) ON t.sku = s.sku
WHEN MATCHED THEN UPDATE SET qty = t.qty + s.qty
WHEN NOT MATCHED THEN INSERT (sku, qty) VALUES (s.sku, s.qty);
MERGE shines for set-based merges — synchronizing a whole staging table into a target with UPDATE + INSERT + DELETE arms in one pass — not just single-row upserts. PostgreSQL extends the standard with WHEN NOT MATCHED BY SOURCE (rows present in the target but absent from the source — useful for expiry/delete) and WHEN NOT MATCHED [BY TARGET] (PostgreSQL — MERGE):
MERGE INTO inventory AS t
USING staging AS s ON t.sku = s.sku
WHEN MATCHED AND t.qty <> s.qty THEN UPDATE SET qty = s.qty
WHEN NOT MATCHED THEN INSERT (sku, qty) VALUES (s.sku, s.qty)
WHEN NOT MATCHED BY SOURCE THEN DELETE;
The AND condition on an arm (here t.qty <> s.qty) makes the arm conditional — a use case ON CONFLICT cannot express as cleanly, which is why MERGE is the right tool for multi-arm set-based work even though ON CONFLICT wins for single-row upserts.
3. INSERT ... ON CONFLICT (PostgreSQL / SQLite)
The PostgreSQL/SQLite upsert. An ordinary INSERT followed by ON CONFLICT (target) DO UPDATE SET ... or DO NOTHING (SQLite — UPSERT). The key subtlety is excluded:
"Column names in the expressions of a DO UPDATE refer to the original unchanged value of the column, before the attempted INSERT. To use the value that would have been inserted had the constraint not failed, add the special 'excluded.' table qualifier to the column name." — SQLite — UPSERT
INSERT INTO page_views (url, views) VALUES (:url, 1)
ON CONFLICT (url) DO UPDATE SET views = page_views.views + excluded.views;
INSERT INTO tags (name) VALUES (:name)
ON CONFLICT (name) DO NOTHING;
A trailing WHERE on the DO UPDATE lets you skip the write entirely when nothing changed — avoiding a no-op row version (and a redundant trigger/updated_at bump). The ON CONFLICT (...) WHERE expr and DO UPDATE SET ... WHERE expr forms are both in the SQLite — UPSERT syntax:
INSERT INTO users (email, name) VALUES (:email, :name)
ON CONFLICT (email) DO UPDATE SET name = excluded.name
WHERE users.name IS DISTINCT FROM excluded.name;
INSERT INTO page_views (url, views) VALUES (:url, 1)
ON CONFLICT (url) DO UPDATE SET views = views + 1;
The conflict target (url) is not optional decoration — see §5.
4. INSERT ... ON DUPLICATE KEY UPDATE (MySQL / MariaDB)
MySQL's spelling. "If you specify an ON DUPLICATE KEY UPDATE clause and a row to be inserted would cause a duplicate value in a UNIQUE index or PRIMARY KEY, an UPDATE of the old row occurs" (MySQL — INSERT ... ON DUPLICATE KEY UPDATE). Unlike ON CONFLICT, there is no explicit conflict target — it fires on any unique key (the footgun in §6). To reference the proposed new values, the old VALUES(col) function is "deprecated, and subject to removal in a future version of MySQL"; use the row alias instead:
INSERT INTO page_views (url, views) VALUES (:url, 1) AS new
ON DUPLICATE KEY UPDATE views = page_views.views + new.views;
INSERT INTO page_views (url, views) VALUES (:url, 1)
ON DUPLICATE KEY UPDATE views = views + VALUES(views);
The three spellings line up: excluded.col (PG/SQLite) ≈ new.col (MySQL) ≈ the source row in a MERGE.
5. Upsert Requires a UNIQUE / PRIMARY KEY Constraint
There is no "conflict" without a constraint that defines one. SQLite is explicit: "The UPSERT processing happens only for uniqueness constraints. A 'uniqueness constraint' is an explicit UNIQUE or PRIMARY KEY constraint within the CREATE TABLE statement, or a unique index" (SQLite — UPSERT). MySQL only acts when a row "would cause a duplicate value in a UNIQUE index or PRIMARY KEY" (MySQL — ON DUPLICATE KEY).
CREATE TABLE users (id BIGSERIAL PRIMARY KEY, email TEXT, name TEXT);
INSERT INTO users (email, name) VALUES (:email, :name)
ON CONFLICT (email) DO UPDATE SET name = excluded.name;
CREATE TABLE users (id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT);
So "make this an upsert" is first a schema question: which column(s) identify the same logical row? That unique key is the contract. Constraint syntax (composite keys, partial indexes, NULLS NOT DISTINCT, and the fact that a default UNIQUE lets multiple NULLs through) is owned by sql-constraints-and-integrity.
6. MERGE Is Not a Concurrency Cure-All
MERGE looks atomic, but it does not guarantee a no-error upsert under concurrency. PostgreSQL warns: "If a target row is modified more than once, a uniqueness violation or cardinality violation will occur," and explicitly steers you elsewhere — "You may also wish to consider using INSERT ... ON CONFLICT ... which offers the ability to run an UPDATE if a concurrent INSERT occurs. There are a variety of differences and restrictions between the two statement types and they are not interchangeable" (PostgreSQL — MERGE, Notes). A MERGE whose WHEN NOT MATCHED arm INSERTs can still hit a duplicate-key error if a concurrent transaction inserts the same key after MERGE checks for a match. The defenses are: (1) a real UNIQUE/PK constraint so the database catches the dup, and (2) for pure single-row insert-or-update, prefer ON CONFLICT / ON DUPLICATE KEY, which are purpose-built to absorb the concurrent insert; otherwise wrap MERGE in a retry on unique-violation. Isolation-level interaction and MVCC are owned by sql-transactions-and-isolation.
7. Portability
| Mechanism | PostgreSQL | SQLite | MySQL / MariaDB | SQL Server / Oracle / DB2 |
|---|
Standard MERGE | v15+ | no | no | yes |
INSERT ... ON CONFLICT | v9.5+ | v3.24+ | no | no |
INSERT ... ON DUPLICATE KEY UPDATE | no | no | yes | no |
| new-value alias | excluded.col | excluded.col | new.col (8.0.19+) | source alias in USING |
MERGE is genuinely standard (SQL:2003/2008) and broadly supported by the "big iron" engines — "PostgreSQL, Oracle Database, IBM Db2, Teradata, ... MS SQL ... support the standard syntax" — while INSERT ON CONFLICT is the PostgreSQL/SQLite idiom and ON DUPLICATE KEY UPDATE is the MySQL one (Wikipedia — Merge (SQL)). No single statement is portable across all four: MySQL and SQLite have no MERGE; only PostgreSQL has both MERGE and ON CONFLICT. Pick by target engine and keep the upsert behind one well-named function. The full divergence table is owned by sql-standard-vs-dialect-map.
8. Who Suffers When the Upsert Is Wrong
An upsert bug almost never fails the unit test — it fails under concurrency or against a second unique index, in production:
- The on-call engineer paged at 2 a.m. for a duplicate-key crash that only appears under load: the app's
SELECT-then-INSERT (§1) raced two requests for the same email, and the second INSERT blew up. The single-statement upsert they didn't write is the fix.
- The analyst reconciling a counter that's quietly low: two concurrent check-then-act paths both took the UPDATE branch and one lost update silently overwrote the other (§1). No error, just a wrong number.
- The maintainer of a MySQL table with two unique indexes, whose
ON DUPLICATE KEY UPDATE fired on the wrong key and updated a row they never meant to touch — "if a=1 OR b=2 matches several rows, only one row is updated ... avoid using an ON DUPLICATE KEY UPDATE clause on tables with multiple unique indexes" (MySQL — ON DUPLICATE KEY). That is silent data loss, not a crash.
- The developer whose
DO UPDATE SET views = views + 1 is a no-op because they forgot excluded. and kept re-writing the old stored value (§3).
The atomic statement, the right unique key, and excluded./new. used deliberately are all gifts to whoever debugs this under load.
9. Routing to Related Skills
sql-relational-and-null-discipline — foundation: set semantics and three-valued logic (a NULL in the conflict key never "matches," because nothing equals NULL).
sql-constraints-and-integrity — the UNIQUE/PRIMARY KEY constraint every upsert depends on (§5), composite keys, and multi-NULL uniqueness.
sql-transactions-and-isolation — why the atomic statement is safe and MERGE is not (§1, §6): isolation levels, MVCC, lost updates, retry-on-unique-violation.
sql-standard-vs-dialect-map — the full MERGE vs ON CONFLICT vs ON DUPLICATE KEY UPDATE divergence table and per-engine availability (§7).
10. Reference Files
High-frequency upsert anti-patterns in LLM-generated SQL, each with wrong/right code and a primary-source citation:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml