Database Deadlock: Causes, Detection, and Prevention

A database deadlock occurs when two or more transactions are waiting for each other to release locks, causing them to block indefinitely. Deadlocks can be detected automatically and resolved by aborting one of the transactions.

Database Deadlock: Causes, Detection, and Prevention

A database deadlock occurs when two or more transactions are each waiting for a lock held by another transaction, creating a circular dependency where no transaction can proceed. Each transaction holds a resource that the other needs, and neither is willing to release its lock until it completes. The result is an indefinite block that can only be resolved by aborting one of the transactions.

Deadlocks are a fact of life in high-concurrency database applications. They are not bugs in the database but rather a natural consequence of concurrent access to shared resources. Understanding why deadlocks happen and how to handle them is essential for building robust applications. To understand deadlocks properly, it is helpful to be familiar with database transactions, database locking, and isolation levels.

Deadlock overview:
Transaction A                    Transaction B
─────────────────────────────────────────────────────────
LOCK A                           LOCK B
    │                                  │
    │                                  │
    ▼                                  ▼
WAIT FOR B ←──── DEADLOCK ────→ WAIT FOR A

Four necessary conditions:
1. Mutual exclusion (locks cannot be shared)
2. Hold and wait (hold locks while waiting for others)
3. No preemption (locks cannot be forcibly taken)
4. Circular wait (cycle of waiting transactions)

What Is a Database Deadlock

A database deadlock is a situation where two or more transactions are stuck waiting for each other to release locks. Each transaction holds a lock on a resource that another transaction needs, and none of the transactions can proceed because they are all waiting. Deadlocks are automatically detected by the database, which chooses a victim transaction to abort, allowing the others to complete.

  • Deadlock Victim: The transaction that the database chooses to abort to break the deadlock.
  • Deadlock Detection: The database periodically checks for cycles in the lock wait graph.
  • Deadlock Prevention: Designing transactions to avoid deadlock conditions.
  • Deadlock Resolution: Automatically aborting the victim and rolling back its changes.
  • Deadlock Retry: The application must retry aborted transactions.

Why Deadlocks Matter

Deadlocks are inevitable in high-concurrency systems. Ignoring them leads to application hangs, failed transactions, and poor user experience. Proper deadlock handling is essential for production applications.

  • Application Availability: Unhandled deadlocks cause transactions to fail, potentially leaving applications in an inconsistent state.
  • User Experience: Deadlocks that are not retried appear as random failures to users.
  • Throughput Reduction: Frequent deadlocks reduce overall system throughput.
  • Resource Waste: Deadlocked transactions hold locks and consume resources until resolved.
  • Data Integrity: Proper deadlock handling ensures that transactions either complete or are cleanly rolled back.

Classic Deadlock Example

Two-transaction deadlock:
Transaction A                    Transaction B
─────────────────────────────────────────────────────────
BEGIN;                           BEGIN;
    UPDATE accounts SET balance = balance - 100 WHERE id = 1;
                                     UPDATE accounts SET balance = balance - 50 WHERE id = 2;
    -- Lock on account 1 held         -- Lock on account 2 held

    UPDATE accounts SET balance = balance + 100 WHERE id = 2;
    -- Waiting for lock on account 2
                                     UPDATE accounts SET balance = balance + 50 WHERE id = 1;
                                     -- Waiting for lock on account 1

DEADLOCK! Neither transaction can proceed.
Database detects deadlock and aborts one transaction (victim).

How Databases Detect Deadlocks

Databases use a wait-for graph to detect deadlocks. Nodes represent transactions, and edges represent "waiting for" relationships. A cycle in the graph indicates a deadlock.

Wait-for graph example:
Transaction A ──waits for──→ Transaction B
      ↑                              │
      │                              │
      └──────────waits for───────────┘

Cycle detected → Deadlock!

Detection methods:
- Periodic polling: Check for cycles at regular intervals
- Timeout-based: Assume deadlock if transaction waits too long
- Edge-chasing: Propagate probes to detect cycles
Deadlock detection in different databases:
PostgreSQL:
- Automatically detects deadlocks using wait-for graph
- Aborts one transaction with error: "deadlock detected"
- Returns error code: 40P01

MySQL (InnoDB):
- Automatically detects deadlocks
- Aborts the transaction that has done the least work
- Returns error: "Deadlock found when trying to get lock"

SQL Server:
- Automatically detects deadlocks
- Aborts the transaction with lower cost
- Returns error: 1205

Oracle:
- Automatically detects deadlocks
- Aborts the transaction that detected the deadlock
- Returns ORA-00060

Common Deadlock Scenarios

1. Circular Update Deadlock

-- Transaction A
UPDATE products SET price = price * 1.1 WHERE category = 'electronics';
UPDATE inventory SET quantity = quantity - 10 WHERE product_id IN (SELECT id FROM products WHERE category = 'electronics');

-- Transaction B (running concurrently)
UPDATE inventory SET quantity = quantity + 100 WHERE product_id = 123;
UPDATE products SET price = price * 0.9 WHERE id = 123;

2. Index Deadlock

-- Transaction A: Updates via index
UPDATE users SET status = 'active' WHERE status = 'pending';

-- Transaction B: Updates via primary key
UPDATE users SET last_login = NOW() WHERE id = 123;

-- Deadlock occurs because locks are acquired in different orders on the index and table

3. Foreign Key Deadlock

-- Transaction A
INSERT INTO orders (customer_id, total) VALUES (1, 100);
-- Locks parent table (customers) for referential integrity check

-- Transaction B
DELETE FROM customers WHERE id = 1;
-- Locks customers table, waiting for orders foreign key check

-- Deadlock: A holds lock on customers? B holds lock on customers? (depends on order)

Preventing Deadlocks

1. Access Tables in Consistent Order

Always access tables in the same order across all transactions. This prevents circular wait conditions.

-- Bad: Different access orders
-- Transaction A: Table1 → Table2
-- Transaction B: Table2 → Table1

-- Good: Same access order
-- Transaction A: Table1 → Table2
-- Transaction B: Table1 → Table2

2. Keep Transactions Short

Short transactions hold locks for less time, reducing the window for deadlocks to occur.

-- Bad: Long transaction with user interaction
BEGIN;
    SELECT * FROM accounts WHERE id = 1;  -- Lock held during user think time
    -- User thinks for 10 seconds...
    UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

-- Good: Short transaction
SELECT * FROM accounts WHERE id = 1;  -- No lock
-- User thinks
BEGIN;
    UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

3. Use Lower Isolation Levels

Lower isolation levels use fewer locks, reducing deadlock probability.

-- Serializable has highest deadlock risk
-- Read Committed has lower deadlock risk
-- Read Uncommitted has no read locks (but risks dirty reads)

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

4. Use Row-Level Locks Instead of Table-Level

Row-level locks allow more concurrency and reduce lock contention compared to table-level locks.

-- Ensure your database uses row-level locking
-- In MySQL: Use InnoDB (not MyISAM)
-- In PostgreSQL: Default is row-level
-- Avoid operations that escalate to table locks:
--   - ALTER TABLE
--   - LOCK TABLE
--   - Large DELETE without WHERE

5. Use SELECT FOR UPDATE NOWAIT or SKIP LOCKED

Instead of waiting indefinitely for locks, fail immediately or skip locked rows.

-- PostgreSQL: NOWAIT (fail if lock not available)
BEGIN;
    SELECT * FROM jobs WHERE status = 'pending' FOR UPDATE NOWAIT;
    -- If lock cannot be acquired, error is thrown immediately
COMMIT;

-- PostgreSQL: SKIP LOCKED (skip locked rows, continue with others)
BEGIN;
    SELECT * FROM jobs WHERE status = 'pending' FOR UPDATE SKIP LOCKED;
    -- Returns only rows that are not locked by other transactions
COMMIT;

Handling Deadlocks in Application Code

Deadlocks cannot be completely prevented. Applications must detect deadlock errors and retry transactions.

Deadlock retry pattern (Python/PostgreSQL):
import time
from psycopg2 import errors

def execute_with_retry(func, max_retries=3):
    for attempt in range(max_retries):
        try:
            return func()
        except errors.DeadlockDetected as e:
            if attempt == max_retries - 1:
                raise
            time.sleep(0.1 * (2 ** attempt))  # Exponential backoff
            continue

@execute_with_retry
def transfer_money():
    with connection.begin():
        connection.execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1")
        connection.execute("UPDATE accounts SET balance = balance + 100 WHERE id = 2")
Deadlock retry pattern (MySQL/Node.js):
async function executeWithRetry(fn, maxRetries = 3) {
    for (let i = 0; i < maxRetries; i++) {
        try {
            return await fn();
        } catch (err) {
            if (err.code !== 'ER_LOCK_DEADLOCK' || i === maxRetries - 1) {
                throw err;
            }
            await new Promise(resolve => setTimeout(resolve, 100 * Math.pow(2, i)));
        }
    }
}

await executeWithRetry(async () => {
    await connection.beginTransaction();
    await connection.execute('UPDATE accounts SET balance = balance - 100 WHERE id = 1');
    await connection.execute('UPDATE accounts SET balance = balance + 100 WHERE id = 2');
    await connection.commit();
});

Monitoring Deadlocks

PostgreSQL:
-- Check deadlock logs
SHOW log_min_messages;  -- Should be 'warning' or lower

-- Query deadlock statistics
SELECT * FROM pg_stat_database WHERE datname = 'your_database';

-- Track deadlock frequency
SELECT datname, deadlocks FROM pg_stat_database;
MySQL:
-- Show the last deadlock
SHOW ENGINE INNODB STATUS\G
-- Look for "LATEST DETECTED DEADLOCK" section

-- Enable deadlock logging
SET GLOBAL innodb_print_all_deadlocks = ON;

-- Check deadlock metrics
SHOW STATUS LIKE 'Innodb_deadlocks';
SQL Server:
-- Capture deadlock graphs using extended events
CREATE EVENT SESSION deadlock_monitor ON SERVER
ADD EVENT sqlserver.xml_deadlock_report
ADD TARGET package0.ring_buffer;

-- Query deadlock frequency
SELECT * FROM sys.dm_tran_locks
WHERE request_status = 'WAIT';

Common Deadlock Mistakes to Avoid

  • Not Retrying Deadlocked Transactions: Deadlocks are transient. Always implement retry logic.
  • Long-Running Transactions: Increase deadlock probability significantly. Keep transactions short.
  • Inconsistent Access Order: Accessing tables in different orders across transactions is a primary deadlock cause.
  • Missing Indexes: Full table scans lock more rows than necessary, increasing deadlock risk.
  • Too High Isolation Level: Serializable isolation greatly increases deadlock probability.
  • Ignoring Deadlock Logs: Deadlock logs contain valuable information about the cause. Analyze them.
Using indexes to reduce deadlocks:
-- Without index: UPDATE locks entire table
-- With index: UPDATE locks only matching rows

CREATE INDEX idx_orders_status ON orders(status);

-- This UPDATE now locks only rows with status = 'pending'
UPDATE orders SET status = 'processed' WHERE status = 'pending' LIMIT 100;

Deadlock Prevention Best Practices

  • Access Tables in Consistent Order: Establish and enforce a standard order for accessing tables across all transactions.
  • Keep Transactions Short: Minimize the time between BEGIN and COMMIT. Do not include user input or external calls.
  • Use Appropriate Isolation Level: Read Committed is sufficient for most applications. Avoid Serializable unless necessary.
  • Add Indexes: Proper indexes reduce the number of rows locked and prevent table scans.
  • Implement Retry Logic: Always retry deadlocked transactions with exponential backoff.
  • Monitor Deadlock Frequency: Track deadlock rates and investigate spikes.
  • Use SELECT FOR UPDATE NOWAIT: For queue processing, use NOWAIT or SKIP LOCKED to avoid waiting.

Frequently Asked Questions

  1. How does the database choose which transaction to abort?
    Different databases use different criteria. MySQL aborts the transaction that has done the least work. PostgreSQL aborts the transaction that detected the deadlock. SQL Server aborts the transaction with lower cost. The specific choice is not guaranteed.
  2. Can deadlocks be completely prevented?
    In high-concurrency systems, no. Deadlocks are a mathematical certainty under certain conditions. However, they can be minimized through proper design and retry logic.
  3. What is the difference between a deadlock and a lock timeout?
    A deadlock is a circular wait condition between transactions. A lock timeout occurs when a transaction waits too long for a single lock (configured by lock_timeout setting). Both result in transaction failure but have different causes.
  4. Do deadlocks happen in read-only transactions?
    Typically no. Read-only transactions do not acquire write locks. However, at Serializable isolation level, read-only transactions may still participate in deadlocks due to predicate locking.
  5. How many retries should I implement for deadlocks?
    3-5 retries with exponential backoff (0.1s, 0.2s, 0.4s, 0.8s, 1.6s) is typical. The exact number depends on your workload and how long deadlocks persist.
  6. What should I learn next after database deadlocks?
    After mastering deadlocks, explore database locking mechanisms, isolation levels in depth, concurrency control, and optimistic locking patterns for complete concurrency management.