Error Medic

SQL Server Deadlock: 'Transaction was deadlocked on lock resources' — Complete Fix Guide

Fix SQL Server deadlock error 1205 fast. Diagnose with system_health XEvents, add retry logic, tune indexes, and prevent future deadlocks in minutes.

Last updated:
Last verified:
2,201 words
Key Takeaways
  • Deadlocks occur when two or more transactions hold locks the other needs, creating a circular wait — SQL Server resolves this by killing the transaction with the lowest deadlock priority (the 'victim'), surfacing error 1205.
  • The most common root causes are inconsistent table/index access order across transactions, missing covering indexes forcing full table scans that escalate to table locks, and long-running transactions holding locks while performing application-side logic.
  • Quick fix path: (1) capture the deadlock XML graph via the system_health Extended Events session, (2) identify the conflicting resources and queries, (3) add a covering index or reorder table access, and (4) wrap the victim query in a retry loop with exponential back-off.
SQL Server Deadlock Fix Approaches Compared
MethodWhen to UseTime to DeployRisk
Retry logic in application layerIntermittent deadlocks on any query; quickest production safeguard1–2 hoursLow — no schema changes
Add covering index on victim tableDeadlock graph shows table/page locks due to index scans15 min (online rebuild)Low–Medium — monitor query plan regressions
Reorder table access in transactionsGraph shows same tables locked in opposite order across SPIDs1–4 hours refactorMedium — requires regression testing
Enable Read Committed Snapshot Isolation (RCSI)Read-write deadlocks dominate; tempdb capacity available5 min (ALTER DATABASE)Medium — tempdb version store growth
SET DEADLOCK_PRIORITY LOW on reporting queriesKnown low-priority batch queries causing deadlocks with OLTPMinutesLow — explicit victim designation
Break long transactions into smaller batchesLock held duration visible in deadlock graph exceeds 5+ secondsHours–DaysMedium — logic restructure required
Apply query hints (NOLOCK / UPDLOCK)Read-only queries deadlocking with writers; dirty reads acceptableMinutesMedium — dirty reads, phantom data risk

Understanding the SQL Server Deadlock Error

When SQL Server detects a circular lock dependency, it immediately terminates one transaction and returns:

Msg 1205, Level 13, State 51, Line 1
Transaction (Process ID 58) was deadlocked on lock | communication buffer resources with another
process and has been chosen as the deadlock victim. Rerun the transaction.

This is distinct from a lock timeout (Msg 1222 — Lock request time out period exceeded) or a simple blocking scenario where one SPID waits indefinitely. A deadlock has a defined resolution: the Lock Monitor background thread (running every 5 seconds by default) detects the cycle, selects a victim based on deadlock priority and transaction cost, and rolls it back.

Deadlocks frequently accompany related SQL Server pain points: slow queries that escalate page locks into table locks, too many connections exhausting the thread pool and extending lock hold time, and out-of-memory conditions that prevent query plan caching and force repeated lock acquisitions.


Step 1: Capture the Deadlock Graph

SQL Server automatically records deadlock events in the system_health Extended Events session (available since SQL Server 2008 R2). No configuration is needed.

Read the deadlock XML from the ring buffer:

SELECT
    xdr.value('@timestamp', 'datetime2') AS deadlock_time,
    xdr.query('.') AS deadlock_graph
FROM (
    SELECT CAST(target_data AS XML) AS target_data
    FROM sys.dm_xe_session_targets t
    JOIN sys.dm_xe_sessions s ON t.event_session_address = s.address
    WHERE s.name = 'system_health'
      AND t.target_name = 'ring_buffer'
) AS data
CROSS APPLY target_data.nodes('//RingBufferTarget/event[@name="xml_deadlock_report"]') AS xdr_nodes(xdr)
ORDER BY deadlock_time DESC;

Save the resulting XML, then open it in SQL Server Management Studio (SSMS) by pasting it into a new .xdl file — SSMS renders it as a visual deadlock graph showing the victim (marked with a skull icon), the processes involved, and the locked resources.

Key elements to read from the graph:

  • <process> nodes: Each involved SPID with currentdb, isolationlevel, clientapp, and the offending T-SQL in <inputbuf>.
  • <resource> nodes: Whether locks are on a KEY (row), PAGE, OBJECT (table), or RID.
  • <owner> and <waiter>: Who holds what and who wants what — the cross-dependency is the deadlock itself.

Step 2: Identify the Root Cause Pattern

Pattern A — Inconsistent Access Order (most common)

Transaction 1 locks Orders then tries to lock OrderItems. Transaction 2 locks OrderItems then tries to lock Orders. Resolution: enforce a consistent lock acquisition order in all code paths touching both tables.

Pattern B — Missing Index Causing Lock Escalation

A SELECT or UPDATE without a supporting index triggers an index scan, acquiring S-locks or U-locks on many pages. When enough pages are locked (default threshold: 5,000 locks or 40% of a table), SQL Server escalates to a TABLE lock, blocking all concurrent writers. Check the deadlock graph lockMode attribute — TAB confirms escalation.

Diagnose with:

SELECT
    esc.object_id,
    OBJECT_NAME(esc.object_id) AS table_name,
    esc.escalation_count,
    esc.page_lock_count
FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) esc
WHERE esc.index_level = 0
ORDER BY esc.escalation_count DESC;

Pattern C — Read-Write Contention (fix with RCSI)

A writer (UPDATE/INSERT/DELETE) holds an X-lock and deadlocks with a reader (SELECT) that holds an S-lock. Under default READ COMMITTED isolation, readers block writers and vice versa. Enabling Read Committed Snapshot Isolation (RCSI) makes readers use row versions from tempdb instead of acquiring S-locks:

ALTER DATABASE YourDatabase SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;

Verify tempdb has sufficient free space first:

SELECT volume_mount_point, available_bytes / 1048576 AS available_mb
FROM sys.dm_os_volume_stats(2, 1); -- filegroup 2 = tempdb data file

Pattern D — Long Transactions Holding Locks

Application code that begins a transaction, calls an external API or performs file I/O, then issues a DML statement holds locks for the entire external call duration. Any lock held beyond ~200 ms significantly raises deadlock probability under concurrency. Look for <process waittime="..." values in the deadlock XML exceeding 1,000 ms.


Step 3: Apply the Fix

Fix 1 — Implement Retry Logic (apply immediately in all cases)

Error 1205 is retryable. The victim transaction is fully rolled back; it is safe to rerun. Implement retry with exponential back-off:

// C# / ADO.NET example
int maxRetries = 3;
int attempt = 0;
while (attempt < maxRetries) {
    try {
        using var tx = conn.BeginTransaction(IsolationLevel.ReadCommitted);
        // ... your DML ...
        tx.Commit();
        break;
    } catch (SqlException ex) when (ex.Number == 1205) {
        attempt++;
        if (attempt >= maxRetries) throw;
        Thread.Sleep(TimeSpan.FromMilliseconds(50 * Math.Pow(2, attempt)));
    }
}

Fix 2 — Add or Tune Indexes

From the deadlock XML <inputbuf>, extract the query and run it through the Query Store or the Missing Index DMVs:

SELECT TOP 10
    mid.statement AS table_name,
    migs.avg_user_impact,
    migs.user_seeks + migs.user_scans AS total_reads,
    'CREATE INDEX IX_' + OBJECT_NAME(mid.object_id) + '_missing ON '
        + mid.statement
        + ' (' + ISNULL(mid.equality_columns, '') 
        + ISNULL(', ' + mid.inequality_columns, '') + ')'
        + ISNULL(' INCLUDE (' + mid.included_columns + ')', '') AS create_statement
FROM sys.dm_db_missing_index_details mid
JOIN sys.dm_db_missing_index_groups mig ON mid.index_handle = mig.index_handle
JOIN sys.dm_db_missing_index_group_stats migs ON mig.index_group_handle = migs.group_handle
ORDER BY migs.avg_user_impact DESC;

Create the index ONLINE to avoid blocking production:

CREATE INDEX IX_Orders_CustomerId_Status
ON dbo.Orders (CustomerId, Status)
INCLUDE (OrderDate, TotalAmount)
WITH (ONLINE = ON, FILLFACTOR = 85);

Fix 3 — Reorder Table Access

Audit every stored procedure and application query touching the deadlocking tables. Establish a canonical order (e.g., always lock parent before child: CustomersOrdersOrderItems) and refactor all T-SQL and ORM queries to match. For ORMs (Entity Framework, Hibernate), explicit lock hints or raw SQL may be necessary.

Fix 4 — Use UPDLOCK to Prevent Conversion Deadlocks

A common pattern: two sessions SELECT with S-lock, both intend to UPDATE, both try to convert to X-lock — neither can because the other holds an S-lock.

-- Instead of:
SELECT @qty = Quantity FROM dbo.Inventory WHERE ProductId = @id;
UPDATE dbo.Inventory SET Quantity = @qty - 1 WHERE ProductId = @id;

-- Use UPDLOCK to acquire U-lock at read time:
SELECT @qty = Quantity FROM dbo.Inventory WITH (UPDLOCK, ROWLOCK)
WHERE ProductId = @id;
UPDATE dbo.Inventory SET Quantity = @qty - 1 WHERE ProductId = @id;

Step 4: Monitor and Validate

After deploying fixes, set up an alert on deadlock rate using the system_health session or a custom XE session targeting xml_deadlock_report. In Azure SQL Database and SQL Managed Instance, deadlock graphs appear natively in Query Store under Intelligent Query Processing.

For on-premises SQL Server, track deadlock count via:

SELECT cntr_value AS deadlocks_per_sec
FROM sys.dm_os_performance_counters
WHERE object_name LIKE '%SQLServer:Locks%'
  AND counter_name = 'Number of Deadlocks/sec'
  AND instance_name = '_Total';

A value that remains non-zero after index and isolation changes indicates additional deadlock sources — repeat the capture-diagnose-fix cycle.

Frequently Asked Questions

sql
-- =============================================================
-- SQL SERVER DEADLOCK DIAGNOSTIC TOOLKIT
-- =============================================================

-- 1. Pull recent deadlock graphs from system_health ring buffer
SELECT
    xdr.value('@timestamp', 'datetime2') AS deadlock_time,
    xdr.query('.') AS deadlock_graph_xml
FROM (
    SELECT CAST(target_data AS XML) AS target_data
    FROM sys.dm_xe_session_targets t
    JOIN sys.dm_xe_sessions s ON t.event_session_address = s.address
    WHERE s.name = 'system_health'
      AND t.target_name = 'ring_buffer'
) AS data
CROSS APPLY target_data.nodes('//RingBufferTarget/event[@name="xml_deadlock_report"]') AS xdr_nodes(xdr)
ORDER BY deadlock_time DESC;

-- 2. Check current blocking chains
SELECT
    r.session_id,
    r.blocking_session_id,
    r.wait_type,
    r.wait_time / 1000.0 AS wait_seconds,
    r.status,
    SUBSTRING(st.text, (r.statement_start_offset/2)+1,
        ((CASE r.statement_end_offset WHEN -1 THEN DATALENGTH(st.text)
          ELSE r.statement_end_offset END - r.statement_start_offset)/2)+1) AS current_statement,
    r.command,
    s.login_name,
    s.program_name
FROM sys.dm_exec_requests r
JOIN sys.dm_exec_sessions s ON r.session_id = s.session_id
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) st
WHERE r.blocking_session_id > 0
ORDER BY r.wait_time DESC;

-- 3. Check lock escalation events by table
SELECT
    OBJECT_NAME(object_id) AS table_name,
    index_id,
    lock_escalation_attempt_count,
    lock_escalation_count,
    page_lock_count,
    row_lock_count
FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL)
WHERE lock_escalation_count > 0
ORDER BY lock_escalation_count DESC;

-- 4. Identify missing indexes contributing to scan-based locks
SELECT TOP 20
    OBJECT_NAME(d.object_id) AS table_name,
    d.equality_columns,
    d.inequality_columns,
    d.included_columns,
    s.avg_user_impact AS estimated_improvement_pct,
    s.user_seeks,
    s.user_scans,
    'CREATE INDEX IX_' + OBJECT_NAME(d.object_id) + '_' + CAST(d.index_handle AS VARCHAR)
        + ' ON ' + d.statement
        + ' (' + ISNULL(d.equality_columns,'')
        + ISNULL(CASE WHEN d.equality_columns IS NOT NULL THEN ', ' ELSE '' END
            + d.inequality_columns, '')
        + ')'
        + ISNULL(' INCLUDE (' + d.included_columns + ')', '')
        + ' WITH (ONLINE=ON);' AS suggested_create_index
FROM sys.dm_db_missing_index_details d
JOIN sys.dm_db_missing_index_groups g ON d.index_handle = g.index_handle
JOIN sys.dm_db_missing_index_group_stats s ON g.index_group_handle = s.group_handle
ORDER BY s.avg_user_impact * (s.user_seeks + s.user_scans) DESC;

-- 5. Confirm RCSI status and enable if needed
SELECT name, is_read_committed_snapshot_on, snapshot_isolation_state_desc
FROM sys.databases
WHERE name = DB_NAME();

-- To enable RCSI (ensure tempdb has space first):
-- ALTER DATABASE YourDatabaseName SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;

-- 6. Check real-time deadlock counter
SELECT cntr_value AS deadlocks_per_sec
FROM sys.dm_os_performance_counters
WHERE object_name LIKE '%Locks%'
  AND counter_name = 'Number of Deadlocks/sec'
  AND instance_name = '_Total';

-- 7. Identify sessions with open transactions holding locks
SELECT
    s.session_id,
    s.login_name,
    s.program_name,
    s.open_transaction_count,
    s.last_request_start_time,
    DATEDIFF(SECOND, s.last_request_start_time, GETDATE()) AS idle_seconds_in_tx,
    t.text AS last_sql
FROM sys.dm_exec_sessions s
OUTER APPLY sys.dm_exec_sql_text(s.most_recent_sql_handle) t
WHERE s.open_transaction_count > 0
  AND s.session_id != @@SPID
ORDER BY idle_seconds_in_tx DESC;
E

Error Medic Editorial

The Error Medic Editorial team comprises senior DevOps engineers, DBAs, and SREs with combined decades of experience managing SQL Server, PostgreSQL, and cloud-native databases in high-concurrency production environments. Our guides are grounded in real incident postmortems and peer-reviewed for technical accuracy.

Sources

Related Articles in SQL Server

Explore More Database Guides