Upsert in SQL Server: MERGE, UPDATE-then-INSERT, and the Race Conditions
Upsert in SQL Server
SQL Server has no INSERT ... ON CONFLICT (PostgreSQL) or INSERT ... ON DUPLICATE KEY UPDATE (MySQL). The dialect gives you two ways to "update if exists, insert if not": the MERGE statement, and the manual UPDATE-then-INSERT pattern. Both work. Both have sharp edges that are genuinely worth understanding before you pick one, because the sharp edges are concurrency bugs -- the kind that pass every test and fail in production.
MERGE Basics
MERGE compares a source rowset against a target table and applies inserts, updates, and deletes in one statement:
MERGE dbo.customers AS tgt
USING dbo.customers_staging AS src
ON tgt.customer_id = src.customer_id
WHEN MATCHED THEN
UPDATE SET tgt.name = src.name,
tgt.email = src.email,
tgt.updated_at = SYSUTCDATETIME()
WHEN NOT MATCHED BY TARGET THEN
INSERT (customer_id, name, email)
VALUES (src.customer_id, src.name, src.email);Notes on the syntax:
MERGEmust be terminated with a semicolon. This is one of the few places T-SQL enforces it.WHEN NOT MATCHED BY TARGET(source row has no target match) triggers the INSERT. TheBY TARGETis optional;WHEN NOT MATCHEDmeans the same thing.WHEN NOT MATCHED BY SOURCE(target row has no source match) is the third clause -- it canUPDATEorDELETEtarget rows absent from the source. This is what makes MERGE a full table-synchronization tool, not just an upsert.- Each
WHENclause can take an extra predicate:WHEN MATCHED AND src.email <> tgt.email THEN UPDATE ...skips no-op updates.
The Single-Row Upsert
For the common "upsert one row by key" case:
MERGE dbo.settings WITH (HOLDLOCK) AS tgt
USING (SELECT @user_id AS user_id, @key AS [key], @value AS value) AS src
ON tgt.user_id = src.user_id AND tgt.[key] = src.[key]
WHEN MATCHED THEN
UPDATE SET tgt.value = src.value
WHEN NOT MATCHED THEN
INSERT (user_id, [key], value) VALUES (src.user_id, src.[key], src.value);That WITH (HOLDLOCK) is not decoration. It is the point of the next section.
The Race Condition Nobody Expects
A widespread belief is that MERGE, being a single statement, is atomic and therefore immune to races. It is atomic (it either fully happens or fully doesn't), but atomicity is not isolation. Under the default READ COMMITTED level, two concurrent MERGEs can both check for a key, both find nothing, and both proceed to insert -- one gets a primary key violation. The read phase and the write phase are not one indivisible step unless you make them one.
The fix is the HOLDLOCK (equivalently SERIALIZABLE) hint shown above: it takes a key-range lock during the match check, so the second MERGE waits instead of racing. Every single-row upsert MERGE should carry WITH (HOLDLOCK). The same reasoning applies to the manual pattern below.
The Manual Pattern: UPDATE Then INSERT
The pre-MERGE idiom, still the preference of a large share of SQL Server practitioners:
SET XACT_ABORT ON;
BEGIN TRAN;
UPDATE dbo.settings WITH (UPDLOCK, SERIALIZABLE)
SET value = @value
WHERE user_id = @user_id AND [key] = @key;
IF @@ROWCOUNT = 0
INSERT INTO dbo.settings (user_id, [key], value)
VALUES (@user_id, @key, @value);
COMMIT;Why this shape:
- UPDATE first, not existence-check first. If the row usually exists, this is one statement in the common path, and
@@ROWCOUNTtells you whether to insert. ASELECT+IF EXISTS+UPDATE/INSERTsequence is three statements and a wider race window. UPDLOCK, SERIALIZABLEon the UPDATE does the same job HOLDLOCK does for MERGE: the key range is locked from the check through the insert, so concurrent callers serialize instead of colliding.- The explicit transaction is required -- without it, the UPDATE and INSERT are separate autocommit transactions and the locks don't span them. See our transactions guide for why
XACT_ABORT ONbelongs there.
Should You Use MERGE at All?
You'll find blanket "never use MERGE" advice all over the internet, mostly tracing back to Aaron Bertrand's 2013 bug compilation. The honest current state (as of July 2026) is more nuanced. Hugo Kornelis re-verified the whole list against modern builds in 2023 and found most items fixed, unreproducible, or not actually MERGE bugs -- but a couple of real ones remain. His revised advice, which we endorse:
- Avoid the
DELETEaction in MERGE (theWHEN NOT MATCHED BY SOURCE THEN DELETEsync pattern is where the remaining serious bugs live). Use a separateDELETEstatement instead. - Be careful when the target is a temporal table.
- Plain insert/update upserts with
HOLDLOCKare fine.
Two more practical MERGE caveats:
- Triggers fire once per action type, and
@@ROWCOUNTinside a trigger reflects the total affected rows for that action -- test triggers on MERGE targets explicitly. - Performance is not automatically better than separate UPDATE + INSERT. For large batch loads, a MERGE and a well-indexed UPDATE-then-INSERT pair are usually comparable; measure, don't assume. Index the join key on both sides either way.
OUTPUT: Seeing What Happened
MERGE has one genuinely unique feature: $action in the OUTPUT clause tells you what happened to each row:
MERGE dbo.customers AS tgt
USING dbo.customers_staging AS src ON tgt.customer_id = src.customer_id
WHEN MATCHED THEN UPDATE SET tgt.name = src.name
WHEN NOT MATCHED THEN INSERT (customer_id, name) VALUES (src.customer_id, src.name)
OUTPUT $action, inserted.customer_id;$action returns 'INSERT', 'UPDATE', or 'DELETE' per row -- useful for audit logs and load reports, and not available in any other T-SQL statement.
Decision Guide
| Scenario | Use |
|---|---|
| Single-row upsert in OLTP code | UPDATE-then-INSERT with UPDLOCK, SERIALIZABLE (or MERGE + HOLDLOCK; both correct) |
| Batch upsert from a staging table | MERGE with insert/update actions + HOLDLOCK |
| Full sync including deletes | MERGE for insert/update, separate DELETE statement |
| Need per-row action audit | MERGE with OUTPUT $action |
| Temporal table target | Manual pattern, or test MERGE very carefully |
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| MERGE without HOLDLOCK | PK violations under concurrency | WITH (HOLDLOCK) |
| Manual pattern without a transaction | Race between UPDATE and INSERT | Wrap in a transaction + UPDLOCK, SERIALIZABLE |
WHEN NOT MATCHED BY SOURCE THEN DELETE against a filtered source | Deletes everything the filter excluded | Filter the target with a CTE/view, or use separate DELETE |
| Missing semicolon after MERGE | Syntax error | Terminate it |
| Assuming MERGE is faster | Sometimes slower | Measure both |
Writing MERGE statements by hand is fiddly -- three clauses, aliased columns, and a hint to remember. Mako's AI autocomplete generates the full skeleton from your table schemas, which removes most of the transcription errors.
Mako connects to SQL Server with AI-powered autocomplete. Try it free at mako.ai.
Skip the terminal. Use Mako.
Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.