Transaction (Process ID 72) was deadlocked on lock resources with another process and has been
chosen as the deadlock victim. Rerun the transaction. (error 1205) means two transactions were
each waiting for a lock the other held. SQL Server broke the circle by rolling one of them back; the
other one finished normally.
Make every transaction that touches the same rows take its locks in the same order, keep transactions short, and retry the victim, which is safe because its work was rolled back in full. All three are shown below.
The error
PS> .\FixLab.exe --transfer 'Server=(localdb)\MSSQLLocalDB;Database=fix7_lab;Integrated Security=true'
session B:
ex.Number = 1205, ex.Class = 13, ex.State = 51
Microsoft.Data.SqlClient.SqlException: Transaction (Process ID 72) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction.
session A: committed
Why it happens
Two money transfers ran at the same time on dbo.Accounts in the test database
fix7_lab. Session A moved 10 from account 1 to account 2, session B moved 20 from
account 2 to account 1. Each updated its "from" row first, which locked it until commit, then tried
the other row and waited. A waited for B, B waited for A, and neither could ever continue. SQL
Server's deadlock monitor noticed, picked B and rolled its transaction back. "Process ID 72" is the
victim's session id, the same number @@SPID returns.
This is the method that did it. The ordered flag is the fix:
static void Transfer(string cs, string name, int from, int to, decimal amount, bool ordered)
{
using var conn = new SqlConnection(cs);
conn.Open();
using var tran = conn.BeginTransaction();
int[] ids = ordered ? [Math.Min(from, to), Math.Max(from, to)] : [from, to];
foreach (var id in ids)
{
using var cmd = new SqlCommand(
"UPDATE dbo.Accounts SET Balance = Balance + @delta WHERE Id = @id", conn, tran);
cmd.Parameters.Add("@delta", SqlDbType.Decimal).Value = id == from ? -amount : amount;
cmd.Parameters.Add("@id", SqlDbType.Int).Value = id;
cmd.ExecuteNonQuery();
Thread.Sleep(500); // keep the first lock long enough for the other session to collide
}
tran.Commit();
Console.WriteLine($"session {name}: committed");
}
The half-second pause stands in for real work between the two updates. In production the window is smaller, which is why deadlocks look random.
The fix
PS> .\FixLab.exe --transfer 'Server=(localdb)\MSSQLLocalDB;Database=fix7_lab;Integrated Security=true' ordered
session A: committed
session B: committed
1 | Maria Garcia | 490.00
2 | David Chen | 510.00
OK (-1 row(s) affected) in 0.0 s
With both sessions updating the lower Id first, B simply waited for A to commit and then ran. No circle, no victim. When you cannot control the order, for example in code you do not own, retry the victim:
static void WithDeadlockRetry(Action work, int attempts = 3)
{
for (int attempt = 1; ; attempt++)
{
try
{
work();
return;
}
catch (SqlException ex) when (ex.Number == 1205 && attempt < attempts)
{
Console.WriteLine($"deadlock victim, retry {attempt}");
Thread.Sleep(100 * attempt);
}
}
}
In the unordered run with retries the output was deadlock victim, retry 1, then both
sessions committed and both balances were back at 500.00. Retry the whole transaction, with a new
connection and transaction, never just the failed statement. SQL Server also kept every deadlock in
its system_health session, which tells you which table and index to look at:
PS> .\FixLab.exe 'Server=(localdb)\MSSQLLocalDB;Database=fix7_lab;Integrated Security=true' "SELECT x.value('(@timestamp)[1]', 'datetime2(0)') AS At, x.value('count(data/value/deadlock/process-list/process)', 'int') AS Processes, x.value('(data/value/deadlock/victim-list/victimProcess/@id)[1]', 'varchar(40)') AS Victim, x.value('(data/value/deadlock/resource-list/keylock/@objectname)[1]', 'varchar(100)') AS LockedObject, x.value('(data/value/deadlock/resource-list/keylock/@indexname)[1]', 'varchar(100)') AS LockedIndex FROM (SELECT CAST(t.target_data AS xml) AS td FROM sys.dm_xe_session_targets t JOIN sys.dm_xe_sessions s ON s.address = t.event_session_address WHERE s.name = 'system_health' AND t.target_name = 'ring_buffer') d CROSS APPLY td.nodes('RingBufferTarget/event[@name=''xml_deadlock_report'']') n(x)"
2026-10-30 02:54:09 | 2 | process1a050a23848 | fix7_lab.dbo.Accounts | PK_Accounts
2026-10-30 02:54:52 | 2 | process1a055ecb088 | fix7_lab.dbo.Accounts | PK_Accounts
2026-10-30 02:54:57 | 2 | process1a055ecb088 | fix7_lab.dbo.Accounts | PK_Accounts
OK (-1 row(s) affected) in 0.6 s
How it was reproduced
A console app from dotnet new console -n FixLab -f net10.0 (.NET SDK 10.0.403) with
Microsoft.Data.SqlClient 7.1.1 ran the two transfers as two tasks on two connections, against SQL
Server 2022 LocalDB 16.0.1210.3. The table held two fictional accounts with 500.00 each, and each
mode was run once; the log above holds three deadlocks because an earlier version of the test
deadlocked once too.
Frequently asked
- Is it safe to retry a transaction after SQL Server error 1205?
- Yes. The victim's whole transaction was rolled back, so rerun all of it from the start on a fresh transaction, a few times at most, with a short pause between attempts.
- How do I prevent deadlocks in SQL Server?
- Access shared rows and tables in the same order in every transaction, keep transactions short, and do not wait for user input or slow calls while holding locks.
- Where can I see the SQL Server deadlock graph?
- The system_health Extended Events session records each deadlock as an xml_deadlock_report event. Query its ring_buffer target from sys.dm_xe_session_targets and read the victim, the processes and the locked index from the XML.
A blocked session that never deadlocks ends in a timeout instead; that case is in Execution Timeout Expired. More decoded errors in the Fixes category.