Skip to content

MERGE and Upsert Patterns in SQL Server

UPSERT = UPDATE if the row exists, otherwise INSERT.

MERGE does INSERT / UPDATE / DELETE in ONE statement by comparing a
SOURCE (new data) with a TARGET (existing table):
MERGE Target AS t
USING Source AS s ON t.Key = s.Key
WHEN MATCHED [AND condition]       THEN UPDATE ...
WHEN NOT MATCHED BY TARGET         THEN INSERT ...
WHEN NOT MATCHED BY SOURCE         THEN DELETE ...   (optional)
OUTPUT $action, inserted.*, deleted.*;               (optional)
;   <-- MERGE MUST end with a semicolon

Typical data engineering use : loading a daily file (staging) into a dimension / master table (incremental load, SCD Type 1 & 2).

Run the sample database script first. Every query below was run on that data, and the Output under each query is the real result.

USE SqlLearningDb;
GO

Topic tables

DROP TABLE IF EXISTS dbo.MergeLog;
DROP TABLE IF EXISTS dbo.ProductMaster;
DROP TABLE IF EXISTS dbo.ProductStaging;
DROP TABLE IF EXISTS dbo.DimCustomer;
DROP TABLE IF EXISTS dbo.CustomerStaging;

-- TARGET : current product master
CREATE TABLE dbo.ProductMaster
(
    ProductCode  VARCHAR(10)   PRIMARY KEY,
    ProductName  VARCHAR(100)  NOT NULL,
    Category     VARCHAR(50)   NOT NULL,
    Price        DECIMAL(10,2) NOT NULL,
    IsActive     BIT           NOT NULL DEFAULT 1,
    LastUpdated  DATETIME2(0)  NOT NULL DEFAULT SYSDATETIME()
);

INSERT INTO dbo.ProductMaster (ProductCode, ProductName, Category, Price, LastUpdated) VALUES
('P001', 'Dell Inspiron 15',   'Laptops',     65000, '2025-01-01'),
('P002', 'HP Pavilion 14',     'Laptops',     85000, '2025-01-01'),
('P003', 'Wireless Mouse',     'Accessories',  1200, '2025-01-01'),
('P004', 'USB-C Hub',          'Accessories',  3500, '2025-01-01'),
('P005', 'Basic Office Chair', 'Furniture',    8500, '2025-01-01'),
('P006', 'Old Model Tablet',   'Mobiles',     18000, '2025-01-01');

-- SOURCE : today's file from the supplier
CREATE TABLE dbo.ProductStaging
(
    ProductCode VARCHAR(10)   NOT NULL,
    ProductName VARCHAR(100)  NOT NULL,
    Category    VARCHAR(50)   NOT NULL,
    Price       DECIMAL(10,2) NOT NULL
);

INSERT INTO dbo.ProductStaging (ProductCode, ProductName, Category, Price) VALUES
('P001', 'Dell Inspiron 15',      'Laptops',     63000),   -- price changed   -> UPDATE
('P002', 'HP Pavilion 14',        'Laptops',     85000),   -- no change       -> skip
('P003', 'Wireless Mouse Pro',    'Accessories',  1500),   -- name+price chg  -> UPDATE
('P004', 'USB-C Hub',             'Accessories',  3500),   -- no change       -> skip
('P005', 'Basic Office Chair',    'Furniture',    8200),   -- price changed   -> UPDATE
('P007', 'Standing Desk',         'Furniture',   22000),   -- new             -> INSERT
('P008', 'Mechanical Keyboard',   'Accessories',  2500);   -- new             -> INSERT
-- P006 is NOT in the file -> deactivate (soft delete)

CREATE TABLE dbo.MergeLog
(
    LogID       INT IDENTITY(1,1) PRIMARY KEY,
    ActionTaken NVARCHAR(10),
    ProductCode VARCHAR(10),
    OldPrice    DECIMAL(10,2),
    NewPrice    DECIMAL(10,2),
    LoggedAt    DATETIME2(0) DEFAULT SYSDATETIME()
);
GO
Messages
(6 rows affected)
(7 rows affected)

Part A: FULL MERGE (insert + update + soft delete) with OUTPUT

SELECT 'BEFORE' AS Stage, * FROM dbo.ProductMaster;

MERGE dbo.ProductMaster AS t
USING dbo.ProductStaging AS s
      ON t.ProductCode = s.ProductCode
WHEN MATCHED AND (t.ProductName <> s.ProductName OR t.Price <> s.Price OR t.Category <> s.Category OR t.IsActive = 0)
    THEN UPDATE SET t.ProductName = s.ProductName,
                    t.Category    = s.Category,
                    t.Price       = s.Price,
                    t.IsActive    = 1,
                    t.LastUpdated = SYSDATETIME()
WHEN NOT MATCHED BY TARGET
    THEN INSERT (ProductCode, ProductName, Category, Price)
         VALUES (s.ProductCode, s.ProductName, s.Category, s.Price)
WHEN NOT MATCHED BY SOURCE AND t.IsActive = 1
    THEN UPDATE SET t.IsActive = 0, t.LastUpdated = SYSDATETIME()     -- soft delete
OUTPUT $action,
       COALESCE(inserted.ProductCode, deleted.ProductCode),
       deleted.Price,
       inserted.Price
INTO dbo.MergeLog (ActionTaken, ProductCode, OldPrice, NewPrice);

SELECT 'AFTER' AS Stage, * FROM dbo.ProductMaster;
SELECT * FROM dbo.MergeLog;                 -- what MERGE did, row by row
SELECT ActionTaken, COUNT(*) AS Cnt FROM dbo.MergeLog GROUP BY ActionTaken;
GO

-- Run the same MERGE again : nothing changes (idempotent load = good ETL design)
Output
StageProductCodeProductNameCategoryPriceIsActiveLastUpdated
BEFOREP001Dell Inspiron 15Laptops65000.0012025-01-01 00:00:00
BEFOREP002HP Pavilion 14Laptops85000.0012025-01-01 00:00:00
BEFOREP003Wireless MouseAccessories1200.0012025-01-01 00:00:00
BEFOREP004USB-C HubAccessories3500.0012025-01-01 00:00:00
BEFOREP005Basic Office ChairFurniture8500.0012025-01-01 00:00:00
BEFOREP006Old Model TabletMobiles18000.0012025-01-01 00:00:00
Messages
(6 rows affected)
Output
StageProductCodeProductNameCategoryPriceIsActiveLastUpdated
AFTERP001Dell Inspiron 15Laptops63000.0012026-10-18 18:22:55
AFTERP002HP Pavilion 14Laptops85000.0012025-01-01 00:00:00
AFTERP003Wireless Mouse ProAccessories1500.0012026-10-18 18:22:55
AFTERP004USB-C HubAccessories3500.0012025-01-01 00:00:00
AFTERP005Basic Office ChairFurniture8200.0012026-10-18 18:22:55
AFTERP006Old Model TabletMobiles18000.0002026-10-18 18:22:55
AFTERP007Standing DeskFurniture22000.0012026-10-18 18:22:55
AFTERP008Mechanical KeyboardAccessories2500.0012026-10-18 18:22:55
Output
LogIDActionTakenProductCodeOldPriceNewPriceLoggedAt
1INSERTP007NULL22000.002026-10-18 18:22:55
2INSERTP008NULL2500.002026-10-18 18:22:55
3UPDATEP00165000.0063000.002026-10-18 18:22:55
4UPDATEP0031200.001500.002026-10-18 18:22:55
5UPDATEP0058500.008200.002026-10-18 18:22:55
6UPDATEP00618000.0018000.002026-10-18 18:22:55
Output
ActionTakenCnt
INSERT2
UPDATE4

Part B: Upsert a Single Row (e.g. from an application)

MERGE version (HOLDLOCK prevents race conditions between sessions)

DECLARE @Code VARCHAR(10) = 'P009', @Name VARCHAR(100) = 'Ergonomic Chair', @Cat VARCHAR(50) = 'Furniture', @Price DECIMAL(10,2) = 12000;

MERGE dbo.ProductMaster WITH (HOLDLOCK) AS t
USING (SELECT @Code AS ProductCode) AS s
      ON t.ProductCode = s.ProductCode
WHEN MATCHED     THEN UPDATE SET ProductName = @Name, Category = @Cat, Price = @Price, LastUpdated = SYSDATETIME()
WHEN NOT MATCHED THEN INSERT (ProductCode, ProductName, Category, Price) VALUES (@Code, @Name, @Cat, @Price);

SELECT * FROM dbo.ProductMaster WHERE ProductCode = @Code;
GO
Messages
(1 row affected)
Output
ProductCodeProductNameCategoryPriceIsActiveLastUpdated
P009Ergonomic ChairFurniture12000.0012026-10-18 18:22:55

UPDATE-then-INSERT pattern (no MERGE). Popular and easy to read.

DECLARE @Code VARCHAR(10) = 'P009', @Price DECIMAL(10,2) = 11500;

BEGIN TRAN;
    UPDATE dbo.ProductMaster WITH (UPDLOCK, SERIALIZABLE)
    SET Price = @Price, LastUpdated = SYSDATETIME()
    WHERE ProductCode = @Code;

    IF @@ROWCOUNT = 0
        INSERT INTO dbo.ProductMaster (ProductCode, ProductName, Category, Price)
        VALUES (@Code, 'Ergonomic Chair', 'Furniture', @Price);
COMMIT TRAN;

SELECT * FROM dbo.ProductMaster WHERE ProductCode = @Code;
GO
Messages
(1 row affected)
Output
ProductCodeProductNameCategoryPriceIsActiveLastUpdated
P009Ergonomic ChairFurniture11500.0012026-10-18 18:22:55

Set-based UPDATE + INSERT (alternative to MERGE for bulk loads)

BEGIN TRAN;
    UPDATE t
    SET t.Price = s.Price, t.ProductName = s.ProductName, t.LastUpdated = SYSDATETIME()
    FROM dbo.ProductMaster t
    JOIN dbo.ProductStaging s ON s.ProductCode = t.ProductCode
    WHERE t.Price <> s.Price OR t.ProductName <> s.ProductName;

    INSERT INTO dbo.ProductMaster (ProductCode, ProductName, Category, Price)
    SELECT s.ProductCode, s.ProductName, s.Category, s.Price
    FROM dbo.ProductStaging s
    WHERE NOT EXISTS (SELECT 1 FROM dbo.ProductMaster t WHERE t.ProductCode = s.ProductCode);
COMMIT TRAN;
GO
Messages
(0 rows affected)
(0 rows affected)

Part C: Slowly Changing Dimension Type 2 (keep history)

When a customer's City changes, close the old row and insert a new row.
CREATE TABLE dbo.DimCustomer
(
    CustomerSK   INT IDENTITY(1,1) PRIMARY KEY,     -- surrogate key
    CustomerID   INT          NOT NULL,             -- business key
    CustomerName VARCHAR(100) NOT NULL,
    City         VARCHAR(50)  NOT NULL,
    Segment      VARCHAR(20)  NOT NULL,
    ValidFrom    DATE         NOT NULL,
    ValidTo      DATE         NULL,                 -- NULL = current
    IsCurrent    BIT          NOT NULL DEFAULT 1
);

INSERT INTO dbo.DimCustomer (CustomerID, CustomerName, City, Segment, ValidFrom)
SELECT CustomerID, CustomerName, City, Segment, '2024-01-01'
FROM dbo.Customers WHERE CustomerID <= 5;

CREATE TABLE dbo.CustomerStaging
(
    CustomerID INT, CustomerName VARCHAR(100), City VARCHAR(50), Segment VARCHAR(20)
);

INSERT INTO dbo.CustomerStaging VALUES
(1, 'Ravi Traders',       'Secunderabad', 'SMB'),        -- city changed     -> new version
(2, 'Infosoft Solutions', 'Bengaluru',    'Corporate'),  -- same             -> nothing
(3, 'Lakshmi Stores',     'Chennai',      'SMB'),        -- segment changed  -> new version
(6, 'Capital Electronics','Delhi',        'SMB');        -- brand new        -> insert
GO

DECLARE @LoadDate DATE = '2025-07-01';

-- Step 1 : MERGE closes changed rows and inserts brand new customers.
--          OUTPUT returns the closed rows so we can insert their new version.
INSERT INTO dbo.DimCustomer (CustomerID, CustomerName, City, Segment, ValidFrom)
SELECT CustomerID, CustomerName, City, Segment, @LoadDate
FROM (
        MERGE dbo.DimCustomer AS t
        USING dbo.CustomerStaging AS s
              ON t.CustomerID = s.CustomerID AND t.IsCurrent = 1
        WHEN MATCHED AND (t.City <> s.City OR t.Segment <> s.Segment)
            THEN UPDATE SET t.IsCurrent = 0, t.ValidTo = DATEADD(DAY, -1, @LoadDate)
        WHEN NOT MATCHED BY TARGET
            THEN INSERT (CustomerID, CustomerName, City, Segment, ValidFrom)
                 VALUES (s.CustomerID, s.CustomerName, s.City, s.Segment, @LoadDate)
        OUTPUT $action AS MergeAction, s.CustomerID, s.CustomerName, s.City, s.Segment
     ) AS changes
WHERE MergeAction = 'UPDATE';

SELECT * FROM dbo.DimCustomer ORDER BY CustomerID, ValidFrom;

-- Point-in-time query : which city was customer 1 in on 2025-03-15?
SELECT CustomerID, City
FROM dbo.DimCustomer
WHERE CustomerID = 1
  AND '2025-03-15' >= ValidFrom AND ('2025-03-15' <= ValidTo OR ValidTo IS NULL);
GO
Messages
(5 rows affected)
(4 rows affected)
(2 rows affected)
Output
CustomerSKCustomerIDCustomerNameCitySegmentValidFromValidToIsCurrent
11Ravi TradersHyderabadSMB2024-01-012025-06-300
71Ravi TradersSecunderabadSMB2025-07-01NULL1
22Infosoft SolutionsBengaluruCorporate2024-01-01NULL1
33Lakshmi StoresChennaiRetail2024-01-012025-06-300
83Lakshmi StoresChennaiSMB2025-07-01NULL1
44Mumbai MartMumbaiRetail2024-01-01NULL1
55Pune Tech ParkPuneCorporate2024-01-01NULL1
66Capital ElectronicsDelhiSMB2025-07-01NULL1
Output
CustomerIDCity
1Hyderabad

MERGE Tips

  • Source must have UNIQUE keys, otherwise error: "attempted to UPDATE or DELETE the same row more than once".
  • Always add "AND (changed columns)" to WHEN MATCHED to avoid useless updates.
  • Use WITH (HOLDLOCK) for concurrent single-row upserts.
  • Careful with NULLs when comparing columns: use ISNULL or "EXISTS (SELECT s.cols EXCEPT SELECT t.cols)".

PRACTICE QUESTIONS 1. Add a duplicate row to ProductStaging and run the MERGE - read the error, then fix it with ROW_NUMBER. 2. Change the MERGE to HARD delete products missing from the file. 3. Build a daily sales summary table and MERGE dbo.Sales into it (upsert by SaleDate + Region). 4. Rewrite the change detection using "EXISTS (SELECT s.* EXCEPT SELECT t.*)" to handle NULLs.