Problems with MERGE usually manifest with combinations of features (especially relatively new ones) that result in highly complex data changing plans. This is a shame because MERGE is undoubtedly a convenient way to express things like an 'upsert'.
Workaround
You could consider the following:
- Create a view over the target table.
- Create
INSTEAD OF trigger(s) for view INSERT and UPDATE actions.
- Write your 'upsert' as a
MERGE against the view.
Advantages:
- The
MERGE sees a simple target, so the data changing plan is not complex.
- SQL Server populates separate internal worktables for inserted and deleted*.
- The trigger code processes simple inserts and updates separately.
- You no longer need an explicit transaction around separate insert and update statements.
Disadvantages:
- The triggers add some overhead.
- You have to remember to target 'upserts' to the view.
- The
OUTPUT clause won't work for inserted references.
This workaround allows you to write a convenient MERGE, but have the resulting actions performed as separate simple inserts and updates.
The usual precautions for concurrency on the MERGE are still needed.
I have used a view assuming you prefer not to have INSTEAD OF triggers on the base table.
* INSTEAD OF triggers do not use row versioning. See my article, Interesting things about INSTEAD OF triggers.
Demo
There are improvements to be made (like possibly combining the triggers). The following code prioritises clarity:
db<>fiddle
Table and sample rows
CREATE TABLE dbo.AccountDetails
(
Email nvarchar(400) NOT NULL
CONSTRAINT PK_AccountDetails
PRIMARY KEY CLUSTERED,
Created datetime NOT NULL
DEFAULT GETUTCDATE(),
Etc nvarchar(max) NULL
);
INSERT dbo.AccountDetails
(Email, Etc)
VALUES
(N'2792@somewhere.com', N'Original 2792'),
(N'3129@somewhere.com', N'Original 3129'),
(N'4726@somewhere.com', N'Original 4726'),
(N'5766@somewhere.com', N'Original 5766');
View
CREATE VIEW
dbo.AccountDetails_Upsert
WITH SCHEMABINDING
AS
SELECT
Email,
Etc
FROM dbo.AccountDetails;
INSTEAD OF INSERT view trigger
CREATE TRIGGER
AccountDetails_Upsert_InsteadOfInsert
ON dbo.AccountDetails_Upsert
INSTEAD OF INSERT
AS
IF ROWCOUNT_BIG() = 0 RETURN;
SET NOCOUNT, XACT_ABORT ON;
SET ROWCOUNT 0;
IF NOT EXISTS (SELECT * FROM Inserted) RETURN;
-- Simple insert
INSERT dbo.AccountDetails
(Email, Etc)
SELECT
Email, Etc
FROM Inserted;
INSTEAD OF UPDATE view trigger
CREATE TRIGGER
AccountDetails_Upsert_InsteadOfUpdate
ON dbo.AccountDetails_Upsert
INSTEAD OF UPDATE
AS
IF ROWCOUNT_BIG() = 0 RETURN;
SET NOCOUNT, XACT_ABORT ON;
SET ROWCOUNT 0;
IF NOT EXISTS (SELECT * FROM Inserted) RETURN;
-- Simple update
UPDATE AD
SET Etc = I.Etc
FROM Inserted AS I
JOIN dbo.AccountDetails AS AD
ON AD.Email = I.Email;
Table-valued MERGE
DECLARE @Changes AS table
(
Email nvarchar(400) NOT NULL PRIMARY KEY,
Etc nvarchar(max) NULL
);
INSERT @Changes
(Email, Etc)
VALUES
-- Update some rows
(N'2792@somewhere.com', N'Updated 2792'),
(N'3129@somewhere.com', N'Updated 3129'),
(N'4726@somewhere.com', N'Updated 4726'),
(N'5766@somewhere.com', N'Updated 5766'),
-- Insert some rows
(N'a@example.com', N'Inserted a'),
(N'b@example.com', N'Inserted b');
MERGE dbo.AccountDetails_Upsert
WITH
(
UPDLOCK,
SERIALIZABLE,
FORCESEEK
) AS V
USING @Changes AS C
ON C.Email = V.Email
WHEN MATCHED THEN
UPDATE
SET Etc = C.Etc
WHEN NOT MATCHED BY TARGET THEN
INSERT (Email, Etc)
VALUES (C.Email, C.Etc);
Results
| Email |
Created |
Etc |
| 2792@somewhere.com |
2025-04-13 09:48:39.687 |
Updated 2792 |
| 3129@somewhere.com |
2025-04-13 09:48:39.687 |
Updated 3129 |
| 4726@somewhere.com |
2025-04-13 09:48:39.687 |
Updated 4726 |
| 5766@somewhere.com |
2025-04-13 09:48:39.687 |
Updated 5766 |
| a@example.com |
2025-04-13 09:48:39.960 |
Inserted a |
| b@example.com |
2025-04-13 09:48:39.960 |
Inserted b |
MERGE plan

Tidy up
DROP VIEW IF EXISTS
dbo.AccountDetails_Upsert;
DROP TABLE IF EXISTS
dbo.AccountDetails;