Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 10 minutes
  • Examples run with oracle-free 23.26.3.0.0, mssql 17.0.5005.3, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 4 of 10 minutes free
Free: MERGE decides, row by row, which of four clauses applies. With a pass: 2 in-depth parts and 2 interview answers, about 6 minutes.

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:

Interactive · stage by stage Four clauses, four rows Step through the MERGE of the day's stock count and see which clause each row of dbo.stock meets.
Four clauses, four rows, step 6 of 6. Showing Match on sku, MATCHED AND count 0, MATCHED, NOT MATCHED BY TARGET, NOT MATCHED BY SOURCE, OUTPUT INTO @changes. In focus: OUTPUT INTO @changes.Match on skuON t.sku = s.skuMATCHED AND count 0DELETE: GHEE-500MATCHEDUPDATE: TEA-250NOT MATCHED BY TARGETINSERT: DAL-1KNOT MATCHED BY SOURCEon_hand 0: RICE-5KOUTPUT INTO @changesone row per changed row

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

stock_merge.sql: the day's count, applied with one MERGE SQL · merge/stock_merge.sql
-- 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, run in the lab Python · stock_merge_session.py
"""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:

Which clause each row meets, and what OUTPUT reports Python · model/merge_model.py
"""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

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

  1. Question 1 of 2 A MERGE has WHEN MATCHED THEN UPDATE first and WHEN MATCHED AND s.on_hand = 0 THEN DELETE second. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It is refused, because with two WHEN MATCHED clauses the first must carry an AND condition

    Put the narrow clause, with its AND condition, first; the second WHEN MATCHED clause takes the rest.

  2. Question 2 of 2 A feed sends only the items whose price changed, and the MERGE has WHEN NOT MATCHED BY SOURCE THEN DELETE. What happens to the items that did not change?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: They are deleted, because the source does not mention them

    Use WHEN NOT MATCHED BY SOURCE only with a complete source, such as a full stock count.

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.

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.

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.

  1. Question 1 of 2 How many WHEN NOT MATCHED BY TARGET clauses may one MERGE have?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: One

    Put the conditions for different inserts into the inserted values, or into the source query.

  2. Question 2 of 2 Why can WHEN NOT MATCHED BY SOURCE THEN UPDATE SET t.on_hand = s.on_hand not work?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: That clause handles target rows without a source row, so there is no s.on_hand to read

    Set target columns to constants or to other target columns in that clause.

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:

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 3 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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 MERGE matches 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.