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.