MERGE, upserts and the OUTPUT clause
Insert-or-update in one statement: the two-statement upsert, MERGE with its three WHEN clauses, $action in OUTPUT, saving OUTPUT into a table, a full sync with NOT MATCHED BY SOURCE, and the traps: duplicate source rows, unscoped deletes and concurrency.
CREATE TABLE dbo.SupplierFeed ( Name nvarchar(60) NOT NULL, CategoryId int NOT NULL, Price decimal(10,2) NOT NULL, Stock int NOT NULL, Unit nvarchar(10) NOT NULL);INSERT INTO dbo.SupplierFeed VALUES (N'Claw hammer', 1, 19.50, 55, N'each'), -- dearer, more stock (N'Cement 25 kg', 2, 7.20, 300, N'bag'), -- unchanged (N'Sand 25 kg', 2, 4.30, 260, N'bag'), -- dearer, more stock (N'Garden spade', 1, 24.00, 15, N'each'), -- new (N'Pipe cutter', 7, 16.50, 10, N'each'); -- newThe upsert in two statements
BEGIN TRANSACTION; UPDATE pSET p.Price = f.Price, p.Stock = f.StockFROM dbo.Products AS pJOIN dbo.SupplierFeed AS f ON f.Name = p.NameWHERE p.Price <> f.Price OR p.Stock <> f.Stock; INSERT INTO dbo.Products (Name, CategoryId, Price, Stock, Unit, AddedOn)SELECT f.Name, f.CategoryId, f.Price, f.Stock, f.Unit, '2024-07-01'FROM dbo.SupplierFeed AS fWHERE NOT EXISTS (SELECT 1 FROM dbo.Products AS p WHERE p.Name = f.Name); COMMIT; SELECT ProductId, Name, Price, StockFROM dbo.ProductsWHERE ProductId IN (1, 4, 5) OR ProductId > 12ORDER BY ProductId;ProductId Name Price Stock
--------- ------------ ----- -----
1 Claw hammer 19.50 55
4 Cement 25 kg 7.20 300
5 Sand 25 kg 4.30 260
13 Garden spade 24.00 15
14 Pipe cutter 16.50 10WHERE on the UPDATE skips rows that would not change, such as the cement. The transaction makes the pair all-or-nothing.
MERGE: one statement
MERGE compares a target table with a source on a join condition, and says what to do in each case:
MERGE dbo.Products AS tUSING dbo.SupplierFeed AS s ON t.Name = s.NameWHEN MATCHED AND (t.Price <> s.Price OR t.Stock <> s.Stock) THEN UPDATE SET t.Price = s.Price, t.Stock = s.StockWHEN NOT MATCHED BY TARGET THEN INSERT (Name, CategoryId, Price, Stock, Unit, AddedOn) VALUES (s.Name, s.CategoryId, s.Price, s.Stock, s.Unit, '2024-07-01')OUTPUT $action AS Action, inserted.ProductId, inserted.Name, deleted.Price AS OldPrice, inserted.Price AS NewPrice;Action ProductId Name OldPrice NewPrice ------ --------- ------------ -------- -------- INSERT 13 Garden spade NULL 24.00 INSERT 14 Pipe cutter NULL 16.50 UPDATE 1 Claw hammer 18.50 19.50 UPDATE 5 Sand 25 kg 4.10 4.30
| Clause | The row is | Allowed actions |
|---|---|---|
WHEN MATCHED [AND …] | ||
WHEN NOT MATCHED [BY TARGET] [AND …] | ||
WHEN NOT MATCHED BY SOURCE [AND …] |
OUTPUT, $action says what happened to each row: INSERT, UPDATE or DELETE. deleted. holds the values before and inserted. the values after, so OldPrice is NULL for the inserts. The cement matched, but its AND test was false, so nothing happened to it and it is not in the output.
OUTPUT INTO: keep a record
OUTPUT … INTO writes the output rows into a table (or table variable) instead of returning them. It works on INSERT, UPDATE, DELETE and MERGE, and makes a change log in the same statement. Here a 10% price rise on Paint is logged:
DECLARE @log TABLE (ProductId int, OldPrice decimal(10,2), NewPrice decimal(10,2)); UPDATE dbo.ProductsSET Price = CAST(Price * 1.10 AS decimal(10,2))OUTPUT inserted.ProductId, deleted.Price, inserted.Price INTO @logWHERE CategoryId = 5; SELECT l.ProductId, p.Name, l.OldPrice, l.NewPrice, l.NewPrice - l.OldPrice AS RiseFROM @log AS lJOIN dbo.Products AS p ON p.ProductId = l.ProductIdORDER BY l.ProductId;ProductId Name OldPrice NewPrice Rise
--------- ------------------ -------- -------- ----
10 Masonry paint 10 L 54.00 59.40 5.40
11 Paint roller set 12.50 13.75 1.25A full sync: NOT MATCHED BY SOURCE
CREATE TABLE dbo.BranchStock (ProductId int PRIMARY KEY, Qty int NOT NULL);INSERT INTO dbo.BranchStock VALUES (1, 40), (3, 65), (9, 5), (12, 0); CREATE TABLE dbo.Stocktake (ProductId int NOT NULL, Qty int NOT NULL);INSERT INTO dbo.Stocktake VALUES (1, 38), (3, 65), (5, 20), (12, 6);MERGE dbo.BranchStock AS tUSING dbo.Stocktake AS s ON t.ProductId = s.ProductIdWHEN MATCHED AND t.Qty <> s.Qty THEN UPDATE SET t.Qty = s.QtyWHEN NOT MATCHED BY TARGET THEN INSERT (ProductId, Qty) VALUES (s.ProductId, s.Qty)WHEN NOT MATCHED BY SOURCE THEN DELETEOUTPUT $action AS Action, COALESCE(inserted.ProductId, deleted.ProductId) AS ProductId, deleted.Qty AS OldQty, inserted.Qty AS NewQty; SELECT * FROM dbo.BranchStock ORDER BY ProductId;Action ProductId OldQty NewQty
------ --------- ------ ------
INSERT 5 NULL 20
UPDATE 1 40 38
DELETE 9 5 NULL
UPDATE 12 0 6
ProductId Qty
--------- ---
1 38
3 65
5 20
12 6inserted. is all NULL, which is why ProductId comes from whichever side has it.
Trap 1: an unscoped NOT MATCHED BY SOURCE
WITH Building AS ( SELECT * FROM dbo.Products WHERE CategoryId = 2)MERGE Building AS tUSING (SELECT Name, Price FROM dbo.SupplierFeed WHERE CategoryId = 2) AS s ON t.Name = s.NameWHEN MATCHED AND t.Price <> s.Price THEN UPDATE SET t.Price = s.PriceWHEN NOT MATCHED BY SOURCE THEN UPDATE SET t.Stock = 0 -- not in the feed: mark as unavailableOUTPUT $action AS Action, inserted.Name, inserted.Price, inserted.Stock;Action Name Price Stock ------ ---------- ----- ----- UPDATE Sand 25 kg 4.30 220
Trap 2: duplicate source rows
CREATE TABLE #Prices (Name nvarchar(60), Price decimal(10,2));INSERT INTO #Prices VALUES (N'Claw hammer', 19.50), (N'Claw hammer', 19.95); MERGE dbo.Products AS tUSING #Prices AS s ON t.Name = s.NameWHEN MATCHED THEN UPDATE SET t.Price = s.Price;Msg 8672, Level 16, State 1, Line 4 The MERGE statement attempted to UPDATE or DELETE the same row more than once. This happens when a target row matches more than one source row. A MERGE statement cannot UPDATE/DELETE the same row of the target table multiple times. Refine the ON clause to ensure a target row matches at most one source row, or use the GROUP BY clause to group the source rows.
Trap 3: two sessions at once
MERGE dbo.Products WITH (HOLDLOCK) AS tUSING (VALUES (N'Garden spade', 1, 24.00, 15, N'each')) AS s (Name, CategoryId, Price, Stock, Unit) ON t.Name = s.NameWHEN MATCHED THEN UPDATE SET t.Price = s.Price, t.Stock = s.StockWHEN NOT MATCHED THEN INSERT (Name, CategoryId, Price, Stock, Unit, AddedOn) VALUES (s.Name, s.CategoryId, s.Price, s.Stock, s.Unit, '2024-07-01')OUTPUT $action AS Action, inserted.ProductId, inserted.Name;Action ProductId Name ------ --------- ------------ INSERT 13 Garden spade
HOLDLOCK (the same as SERIALIZABLE for this table) keeps a lock on the key range it checked until the statement ends, so a second session waits instead of inserting the same name. The same applies to the two-statement upsert: use WITH (UPDLOCK, HOLDLOCK) on the existence check inside the transaction. Locking is covered in the transactions module.
Show a hint
Show the solution
MERGE dbo.Products AS tUSING (VALUES (N'Cordless drill', 84.00, 20)) AS s (Name, Price, Stock) ON t.Name = s.NameWHEN MATCHED THEN UPDATE SET t.Price = s.Price, t.Stock = s.StockWHEN NOT MATCHED THEN INSERT (Name, CategoryId, Price, Stock, Unit, AddedOn) VALUES (s.Name, 1, s.Price, s.Stock, N'each', '2024-07-01')OUTPUT $action AS Action, inserted.ProductId, deleted.Price AS OldPrice, inserted.Price AS NewPrice;Action ProductId OldPrice NewPrice ------ --------- -------- -------- UPDATE 2 89.00 84.00
Remember
An upsert updates the rows that exist and inserts the rest: UPDATE … JOIN, then INSERT … WHERE NOT EXISTS, in a transaction. MERGE does it in one statement: target, USING source, ON condition, and WHEN clauses. WHEN MATCHED (update/delete), NOT MATCHED BY TARGET (insert), NOT MATCHED BY SOURCE (update/delete). OUTPUT $action, deleted.* and inserted.* show what happened to each row; OUTPUT INTO saves it. MERGE must end with a semicolon. Scope NOT MATCHED BY SOURCE with a CTE on the target, or it touches every row the source omits. A target row matched by two source rows is an error: deduplicate the source first. Concurrent upserts need WITH (HOLDLOCK), or UPDLOCK, HOLDLOCK on the two-statement version.