powershelldba.de

T-SQL MERGE: The Synchronization Statement That Needs Caution

MERGE promises to INSERT, UPDATE, and DELETE in a single statement. It's powerful — and notorious for hidden bugs. Here's how to use it safely.

What is MERGE?

MERGE is a single T-SQL statement that compares two tables and synchronizes them:

MERGE INTO target_table AS t
USING source_table AS s
ON t.id = s.id
WHEN MATCHED THEN
    UPDATE SET t.value = s.value
WHEN NOT MATCHED BY TARGET THEN
    INSERT (id, value) VALUES (s.id, s.value)
WHEN NOT MATCHED BY SOURCE THEN
    DELETE;

In a single statement, you can:

It's elegant. It's concise. It should be perfect for ETL and data synchronization. And it would be — if it didn't have a reputation for silent data corruption.

Why MERGE is Appealing

Without MERGE, synchronizing two tables is verbose:

-- The old way (separate statements)
UPDATE target SET value = s.value
FROM source s
WHERE target.id = s.id;

INSERT INTO target (id, value)
SELECT id, value FROM source
WHERE id NOT IN (SELECT id FROM target);

DELETE FROM target
WHERE id NOT IN (SELECT id FROM source);

With MERGE, it's one statement. Cleaner. Allegedly atomic. In theory, perfect.

The Gotchas: Why MERGE Has a Bad Reputation

Gotcha 1: Multiple Matches

If your JOIN condition matches a row in target to multiple rows in source, MERGE executes the WHEN MATCHED clause multiple times for the same target row:

Example:
Target: id=1, value=100
Source: id=1, value=200 AND id=1, value=300

MERGE tries to update id=1 twice (once per source match).
Result: Unpredictable behavior or error.
-- This causes issues
MERGE INTO Orders AS t
USING OrderHistory AS s
ON t.OrderID = s.OrderID  -- ← What if one OrderID has multiple entries in history?
WHEN MATCHED THEN
    UPDATE SET t.Amount = s.Amount;  -- Updates multiple times?

SQL Server will either error or produce unexpected results. There's no atomicity guarantee here.

Gotcha 2: OUTPUT Clause Unreliability

MERGE with OUTPUT can report incorrect row counts or duplicates:

MERGE INTO target AS t
USING source AS s
ON t.id = s.id
WHEN MATCHED THEN UPDATE SET t.value = s.value
WHEN NOT MATCHED BY TARGET THEN INSERT (id, value) VALUES (s.id, s.value)
OUTPUT inserted.id, deleted.id, $action;  -- ← OUTPUT can be wrong

The OUTPUT clause may report rows that weren't actually modified, or miss rows that were. This is a known SQL Server bug affecting specific versions.

Gotcha 3: NONCLUSTERED Index Hints Break MERGE

If the target table has a nonclustered index and you hint it, MERGE can behave unexpectedly:

MERGE INTO target WITH (INDEX(idx_target)) AS t  -- ← Can cause issues
USING source AS s
ON t.id = s.id
WHEN MATCHED THEN UPDATE SET t.value = s.value;

The hint can force inefficient execution plans or cause deadlocks.

Gotcha 4: Execution Order is Not Guaranteed

You might assume MERGE processes WHEN clauses in order (MATCHED first, then NOT MATCHED BY TARGET, then NOT MATCHED BY SOURCE). It doesn't:

-- You might assume:
-- 1. Updates matched rows
-- 2. Inserts new rows
-- 3. Deletes old rows

-- But SQL Server might execute in any order, or in parallel
-- This can cause constraint violations if not careful

If your tables have foreign keys or unique constraints, MERGE can violate them mid-execution.

Gotcha 5: Triggers Fire Per Row, Not Per Statement

If the target table has triggers, they fire for each row modified:

-- Target table has an AFTER UPDATE trigger
-- MERGE updates 1000 rows
-- Trigger fires 1000 times
-- If trigger is expensive, performance degrades dramatically

You might expect one trigger invocation per MERGE. You get one per affected row.

Gotcha 6: MERGE Doesn't Handle Distributed Transactions Well

Using MERGE across linked servers or in a distributed transaction can cause locking issues or deadlocks.

Safe MERGE Patterns

Pattern 1: Simple Upsert (No DELETE)

If you only INSERT or UPDATE (no DELETE), MERGE is safe:

MERGE INTO target AS t
USING source AS s
ON t.id = s.id
WHEN MATCHED AND t.value <> s.value THEN
    UPDATE SET t.value = s.value, t.updated = GETDATE()
WHEN NOT MATCHED BY TARGET THEN
    INSERT (id, value, created)
    VALUES (s.id, s.value, GETDATE());
-- Add a condition to avoid updating if no change

This is relatively safe as long as:

Pattern 2: Validate Before Merge

Ensure no duplicate keys in source:

-- Validate source has no duplicates
IF EXISTS (
    SELECT id, COUNT(*)
    FROM source
    GROUP BY id
    HAVING COUNT(*) > 1
)
BEGIN
    RAISERROR('Source has duplicate keys', 16, 1);
    RETURN;
END;

-- Now safe to MERGE
MERGE INTO target AS t
USING source AS s
ON t.id = s.id
WHEN MATCHED THEN UPDATE SET t.value = s.value
WHEN NOT MATCHED BY TARGET THEN INSERT (id, value) VALUES (s.id, s.value);

Pattern 3: Separate Statements (Safer Alternative)

If synchronization is critical, use separate UPDATE/INSERT/DELETE statements:

BEGIN TRANSACTION;

-- Update matched rows
UPDATE target
SET value = s.value
FROM source s
WHERE target.id = s.id;

-- Insert new rows
INSERT INTO target (id, value)
SELECT s.id, s.value
FROM source s
WHERE NOT EXISTS (SELECT 1 FROM target WHERE id = s.id);

-- Delete rows not in source
DELETE FROM target
WHERE NOT EXISTS (SELECT 1 FROM source WHERE source.id = target.id);

COMMIT TRANSACTION;

Longer, but each statement is predictable and can be debugged independently.

When to Use MERGE vs. Alternatives

Scenario MERGE? Alternative
Simple upsert (insert/update) ✓ Safe if no duplicates Separate UPDATE + INSERT
Complex sync (insert/update/delete) ⚠ Risky Separate statements
ETL with large volumes ⚠ Test first Separate statements (better control)
Real-time sync with triggers ✗ No (triggers fire per row) Separate statements + one trigger call
Distributed transactions ✗ No (locking issues) Separate statements
Cross-server sync (linked servers) ✗ No (complex locking) INSERT/UPDATE via linked server

Real-World Example: The Data Duplication Bug

A company used MERGE to sync their customer master data nightly:

MERGE INTO Customers t
USING CustomerStaging s
ON t.CustomerID = s.CustomerID
WHEN MATCHED THEN UPDATE SET t.Name = s.Name, t.Email = s.Email
WHEN NOT MATCHED BY TARGET THEN INSERT (...) VALUES (...)
WHEN NOT MATCHED BY SOURCE THEN DELETE;

One night, the staging table accidentally had duplicate customer IDs (data quality issue). MERGE encountered multiple matches for the same target customer. The behavior was undefined — some customers got updated multiple times, others were deleted and reinserted.

Result: 50,000 customer records with corrupted email addresses. Customer emails bounced. Support got flooded. Investigation took 8 hours.

Root cause: MERGE was used without validating source data uniqueness.

Lesson: Always validate source data before MERGE. Use separate statements for critical synchronization.

Best Practices

1. Validate Source Data First

-- Before any MERGE, check:
-- No duplicate keys
-- No NULL values in join columns
-- All foreign keys are valid

2. Add Conditions to WHEN Clauses

WHEN MATCHED AND (t.value <> s.value OR t.value IS NULL)
    THEN UPDATE ...  -- Only update if actually different

3. Test with Real Data

Don't assume MERGE works until you've tested it with actual source/target data, including edge cases (duplicates, NULLs, constraints).

4. Use Separate Statements for Critical Data

For financial, medical, or audit-critical data, avoid MERGE. Use separate UPDATE/INSERT/DELETE statements in a transaction.

5. Monitor MERGE Performance

MERGE can be inefficient. Compare execution time against separate statements. Separate statements often run faster and use better execution plans.

6. Document Edge Cases

If you use MERGE, document:

The Verdict

MERGE is convenient for simple upserts but problematic for complex synchronization. When in doubt, use separate INSERT/UPDATE/DELETE statements. They're more predictable, easier to debug, and often faster.

MERGE has its place in simple, well-tested scenarios. But for production ETL and critical data sync, separate statements give you more control and fewer surprises.

If you do use MERGE, always validate source data, test thoroughly, and monitor performance. And document why you chose MERGE instead of safer alternatives.