PL/SQL & T-SQL (Oracle and SQL Server) Module 4 – Working with sets: temporary tables, MERGE and cursors
T-SQL MERGE, OUTPUT and safe upserts
Apply a feed with one T-SQL MERGE, record every action with OUTPUT, avoid errors 10713, 8672 and 334, and keep two sessions from inserting one key.
What you will learn
- Write MERGE with WHEN MATCHED, WHEN NOT MATCHED BY TARGET and WHEN NOT MATCHED BY SOURCE
- Capture what MERGE did with OUTPUT $action and OUTPUT INTO
- Prevent the duplicate-source error and the race between two concurrent upserts
- Decide between MERGE and separate UPDATE and INSERT statements
Before you start
What you’ll learn · Before you start
What you will learn
- Write MERGE with WHEN MATCHED, WHEN NOT MATCHED BY TARGET and WHEN NOT MATCHED BY SOURCE
- Capture what MERGE did with OUTPUT $action and OUTPUT INTO
- Prevent the duplicate-source error and the race between two concurrent upserts
- Decide between MERGE and separate UPDATE and INSERT statements
Before you start
On this page
The day’s stock count has 3 rows, dbo.stock has 3 items, and applying the count takes four changes: one item is updated, one deleted at zero, one inserted and one, missing from the count, set to nothing on hand. One T-SQL MERGE statement makes all four, and its OUTPUT clause says what it did to each row.
You will write a T-SQL MERGE with every kind of WHEN clause, record each change with OUTPUT and $action, avoid
errors 10713, 8672 and 334, write safe upserts that keep two sessions from inserting the same key, and decide when separate UPDATE and INSERT
statements are the better choice.
MERGE decides, row by row, which of four clauses applies
MERGE dbo.stock AS t USING <source> AS s ON t.sku = s.sku pairs every target row with the source rows that match it,
and then hands each row to the first clause that applies. Microsoft’s MERGE page sets the limits. WHEN MATCHED
updates or deletes a target row that has a partner; there may be two such clauses, one that updates and one that
deletes, and the first must carry an AND condition. WHEN NOT MATCHED BY TARGET, or just WHEN NOT MATCHED,
inserts a source row that has none, and there may be only one. WHEN NOT MATCHED BY SOURCE updates or deletes a
target row that the source does not mention, and its condition may read only the target’s columns. Step through the
lab’s count:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Every row is matched first. The ON clause pairs the 3 rows of the count with the 3 rows of dbo.stock by SKU. Each pair, and each row left without a partner, then meets the clauses in order.
- A zero count deletes. GHEE-500 is counted at 0, so the first WHEN MATCHED clause, the one with AND s.on_hand = 0, deletes the row. A second WHEN MATCHED clause only sees rows the first one skipped.
- Any other match updates. TEA-250 matches with a count of 52, so the plain WHEN MATCHED clause sets on_hand from 40 to 52 and the price to 185.00.
- A new SKU is inserted. DAL-1K has no row in dbo.stock, so WHEN NOT MATCHED BY TARGET inserts it with its count of 40. A MERGE may have only one such clause.
- Missing from the count. RICE-5K is in dbo.stock but not in the count, so WHEN NOT MATCHED BY SOURCE sets its on_hand from 25 to 0. That clause may only read the target's columns.
- OUTPUT says what happened. OUTPUT $action writes INSERT, UPDATE or DELETE for every row it touched into @changes, with the old and new counts from deleted and inserted, 4 rows in all.
The lab script runs that MERGE inside a transaction, prints the rows its OUTPUT kept, and rolls the changes back,
so the practice data stays as it was loaded:
-- Engine: SQL Server 2025 CU9 (17.0.5005.3); database lab with the practice schema. Client: sqlcmd.
-- One MERGE applies the day's stock count to dbo.stock: an update, a delete at zero, an insert, and a row missing from
-- the count. OUTPUT $action ... INTO @changes records what happened to each row; merge_model.py prints the same table.
-- The ROLLBACK at the end keeps the practice data as it was loaded.
SET NOCOUNT ON;
DECLARE @changes TABLE (action nvarchar(10) NOT NULL, sku varchar(12) NOT NULL, old_on_hand int NULL, new_on_hand int NULL);
BEGIN TRANSACTION;
MERGE dbo.stock AS t
USING (VALUES ('TEA-250', N'Assam tea 250 g', 185.00, 52), -- counted: 52 bags at a new price
('GHEE-500', N'Ghee 500 g', 420.00, 0), -- counted: none left
('DAL-1K', N'Toor dal 1 kg', 160.00, 40)) -- a new item
AS s (sku, item_name, unit_price, on_hand)
ON t.sku = s.sku
WHEN MATCHED AND s.on_hand = 0 THEN
DELETE -- GHEE-500: no orders refer to it
WHEN MATCHED THEN
UPDATE SET t.on_hand = s.on_hand, t.unit_price = s.unit_price -- TEA-250: 40 becomes 52
WHEN NOT MATCHED BY TARGET THEN
INSERT (sku, item_name, unit_price, on_hand)
VALUES (s.sku, s.item_name, s.unit_price, s.on_hand) -- DAL-1K
WHEN NOT MATCHED BY SOURCE THEN
UPDATE SET t.on_hand = 0 -- RICE-5K was not counted: nothing on hand
OUTPUT $action, COALESCE(inserted.sku, deleted.sku), deleted.on_hand, inserted.on_hand
INTO @changes (action, sku, old_on_hand, new_on_hand); -- the semicolon is required after a MERGE
SELECT action, sku, old_on_hand, new_on_hand FROM @changes ORDER BY sku;
-- INSERT DAL-1K NULL 40 | DELETE GHEE-500 0 NULL | UPDATE RICE-5K 25 0 | UPDATE TEA-250 40 52
ROLLBACK TRANSACTION; -- the practice data stays as loaded
SELECT action, COUNT(*) AS rows_affected FROM @changes GROUP BY action ORDER BY action; -- DELETE 1, INSERT 1, UPDATE 2
GO """stock_merge.sql in the lab: sqlcmd runs it as sa in the database lab, on a freshly loaded practice schema."""
from mssql_session import Lab
with Lab() as lab:
lab.sqlcmd("merge/stock_merge.sql") Output
$ sqlcmd -S localhost -U sa -C -d lab -i merge/stock_merge.sql action sku old_on_hand new_on_hand ------ -------- ----------- ----------- INSERT DAL-1K NULL 40 DELETE GHEE-500 0 NULL UPDATE RICE-5K 25 0 UPDATE TEA-250 40 52 action rows_affected ------ ------------- DELETE 1 INSERT 1 UPDATE 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stock_merge_session.py
The model applies the same clauses to the same rows; press Edit, add ("TEA-250", 60) to COUNT, and run it again:
"""What one MERGE does to each row: a model of stock_merge.sql and the rules on Microsoft's MERGE page.
The clauses are tried in order for every row. A target row that matches a source row is
deleted when the first WHEN MATCHED clause's condition (on_hand = 0) holds, and updated
otherwise; a source row without a target row is inserted; a target row without a source row
gets on_hand 0 (WHEN NOT MATCHED BY SOURCE). Two source rows for one target row stop the
statement with error 8672, and a new key sent twice meets the primary key (error 2627), before
anything changes. Edit COUNT and run it again.
"""
STOCK = {"TEA-250": 40, "RICE-5K": 25, "GHEE-500": 0} # dbo.stock as loaded: sku -> on_hand
COUNT = [ # the day's count: sku, on_hand
("TEA-250", 52),
("GHEE-500", 0),
("DAL-1K", 40),
]
keys = [sku for sku, _ in COUNT]
duplicates = sorted({sku for sku in keys if keys.count(sku) > 1 and sku in STOCK})
new_twice = sorted({sku for sku in keys if keys.count(sku) > 1 and sku not in STOCK})
if duplicates:
print(f"error 8672: target row {duplicates[0]} matches more than one source row; nothing changes")
elif new_twice:
print(f"error 2627: {new_twice[0]} would be inserted twice into the primary key; nothing changes")
else:
changes = []
for sku, on_hand in COUNT:
if sku in STOCK and on_hand == 0:
changes.append(("DELETE", sku, STOCK[sku], None))
elif sku in STOCK:
changes.append(("UPDATE", sku, STOCK[sku], on_hand))
else:
changes.append(("INSERT", sku, None, on_hand))
for sku, on_hand in STOCK.items():
if sku not in keys:
changes.append(("UPDATE", sku, on_hand, 0))
print(f"{'$action':<8} {'sku':<9} {'deleted.on_hand':>15} {'inserted.on_hand':>16}")
for action, sku, old, new in sorted(changes, key=lambda c: c[1]):
print(f"{action:<8} {sku:<9} {str(old) if old is not None else 'NULL':>15} {str(new) if new is not None else 'NULL':>16}")
summary = {a: sum(1 for c in changes if c[0] == a) for a in ("DELETE", "INSERT", "UPDATE")}
print(", ".join(f"{a} {n}" for a, n in summary.items())) Output
$action sku deleted.on_hand inserted.on_hand INSERT DAL-1K NULL 40 DELETE GHEE-500 0 NULL UPDATE RICE-5K 25 0 UPDATE TEA-250 40 52 DELETE 1, INSERT 1, UPDATE 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 merge_model.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
It reports an insert for DAL-1K, a delete for GHEE-500 at 0, an update of RICE-5K from 25 to 0 because the count left
it out, and an update of TEA-250 from 40 to 52. With TEA-250 in the count twice, the model stops with error 8672
before anything changes, which the third section explains. Note the order of the WHEN MATCHED clauses in the script:
the one with AND s.on_hand = 0 comes first, because the second only sees the rows the first did not take.
WHEN NOT MATCHED BY SOURCE on a partial feed
Wrong: a feed that sends only the items that changed, merged with WHEN NOT MATCHED BY SOURCE THEN DELETE. Every
item that did not change today is deleted. Right: use that clause only when the source is complete, such as a full
stock count, and for partial feeds leave it out.
Checkpoint
Check: The clauses of MERGE
OUTPUT, and the three errors MERGE raises Pro: opens with a Pro, Premium or Ultimate pass
A MERGE can report every change it made, and three mistakes stop it before it changes anything. This part keeps the report with OUTPUT INTO, then raises errors 10713, 8672 and 334 in the lab and fixes each one.
In this part:
- OUTPUT records what MERGE did to every row
- Three errors that MERGE raises, and how to avoid them
2 runnable examples (Python, SQL) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Upsert races, and when MERGE is the right statement Pro: opens with a Pro, Premium or Ultimate pass
Two sessions that upsert the same new key at the same moment can both decide to insert it. This part closes that race with HOLDLOCK or UPDLOCK and the primary key, then matches common jobs with MERGE or with separate statements.
In this part:
- Two sessions can both decide to insert
- MERGE or separate UPDATE and INSERT
1 diagram · 2 runnable examples (Python, SQL) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: clause rules, errors and races
The questions cover the whole lesson, from the clause rules and what OUTPUT keeps to the three errors, the race and
the choice of statement. Answer from the sections above; each explanation names the rule that decides it:
Practice that adapts to you
Practise this lesson
2 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): which WHEN clauses can a MERGE have, and which rows does each one handle?
WHEN MATCHED updates or deletes a target row that has a partner in the source. There may be two of them, one that
updates and one that deletes, and the first must carry an AND condition, because each row goes to the first clause
that applies. WHEN NOT MATCHED BY TARGET, or just WHEN NOT MATCHED, inserts a source row that has no partner, and
there may be only one. WHEN NOT MATCHED BY SOURCE updates or deletes a target row that the source does not mention,
and its condition may read only the target’s columns. Use it only with a complete source: on a partial feed it changes
every row the feed left out.
Two longer questions follow, one about a nightly feed that fails with error 8672 and one about duplicate rows that appear under load; work out your answer before you open each first step:
-
Mid to senior
A nightly MERGE applies a supplier's price feed to dbo.stock and has worked for a year. Last night it failed with error 8672, and this morning's prices are stale. Explain the error, change the job so that it cannot fail this way again, and say what you would report to the supplier.
-
Mid to senior
An API registers a customer's device with IF NOT EXISTS (SELECT … WHERE device_id = @id) INSERT …. Under load the device table collects duplicate rows, and since a unique constraint was added, callers get error 2627 a few times an hour. Explain the race and write an upsert that is safe under concurrency.
Flashcards · 3 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which clauses can a MERGE have, and how many of each?
WHEN MATCHED updates or deletes a target row that has a partner, and there may be two such clauses, one updating and one deleting, the first carrying an AND condition. WHEN NOT MATCHED BY TARGET inserts a source row that has none, and there may be only one. WHEN NOT MATCHED BY SOURCE changes a target row the source does not mention, reading only target columns.
-
Why does the order of two WHEN MATCHED clauses matter?
Each row goes to the first clause that applies, so the second only sees the rows the first did not take. The clause with the narrower condition, such as AND s.on_hand = 0 for the rows to delete, has to come first.
-
When is WHEN NOT MATCHED BY SOURCE THEN DELETE safe?
Only when the source is complete, such as a full stock count. On a feed that sends just the items that changed, it deletes every row the feed left out.
Key takeaways
- A
MERGEmatches source and target rows and gives each row to the first clause that applies: matched, not matched by target, or not matched by source. - Safe upserts insert a missing key exactly once, even when two sessions run the same upsert at the same moment.
Terms in this lesson
- OUTPUT clause
- A clause of INSERT, UPDATE, DELETE and MERGE that returns one row for every row the statement changed, with the new values under inserted and the old ones under deleted.
- range lock
- A lock on a range of index keys, including keys that do not exist yet, taken under SERIALIZABLE or HOLDLOCK so that no other session can insert into the range until the transaction ends.
- upsert
- An update of a row when its key exists and an insert when it does not, done as one unit of work, for example with MERGE or with UPDATE followed by INSERT.
References
- MERGE (Transact-SQL) (Microsoft)
- OUTPUT clause (Transact-SQL) (Microsoft)
- Inserting, Updating, and Deleting Data by Using MERGE (SQL Server 2008 R2) (Microsoft)
- Table hints (Transact-SQL) (Microsoft)
- Transaction locking and row versioning guide (Microsoft)
- CREATE TRIGGER (Transact-SQL) (Microsoft)
- Database engine events and errors (0 to 999) (Microsoft)
- Database engine events and errors (2000 to 2999) (Microsoft)
- Database engine events and errors (8000 to 8999) (Microsoft)
- Database engine events and errors (10000 to 10999) (Microsoft)
Report a problem with this lesson
Kept only in this browser. Your Learn progress