Avoid Race Conditions and Deadlocks in Django (Step-by-Step Guide)

Table of Contents

Table of Contents

Introduction

Race conditions and deadlocks are silent killers in Django applications that can cause data corruption, inconsistent states, and mysterious bugs that are hard to reproduce. When multiple processes or threads access the same database records simultaneously, you're walking on thin ice.

This guide shows you how to use Django select_for_update() and transaction.atomic() to build bulletproof database operations that prevent these issues.

The Problem: Race Conditions and Deadlocks

Imagine this scenario:Your Django app has a user balance system where multiple API calls can update the same user's balance simultaneously.

# DANGEROUS CODE - Race condition prone
def update_user_balance(user_id, amount):
    user = User.objects.get(id=user_id)
    user.balance += amount
    user.save()

What happens:

  1. API call 1 reads user balance: $100
  2. API call 2 reads user balance: $100
  3. API call 1 adds $50: $100 + $50 = $150
  4. API call 2 adds $30: $100 + $30 = $130
  5. Result: User loses $20! API call 1's change is overwritten.

This is a classic race condition. Without proper locking, concurrent operations can read stale data and overwrite each other's changes.

Step-by-Step Implementation

Solution 1: Using select_for_update() for Row-Level Locking

select_for_update() locks the selected rows until the transaction completes, preventing other operations from modifying them.

from django.db import transaction

def update_user_balance_safe(user_id, amount):
    with transaction.atomic():
        # Lock the user row for update
        user = User.objects.select_for_update().get(id=user_id)
        user.balance += amount
        user.save()

How it works:

  • select_for_update() acquires a row-level lock
  • Other transactions must wait until this transaction completes
  • The lock is automatically released when the transaction ends

Solution 2: Handling Complex Multi-Table Operations

For operations involving multiple tables, use transaction.atomic() to ensure all-or-nothing execution:

def transfer_money(from_user_id, to_user_id, amount):
    with transaction.atomic():
        # Lock both users to prevent concurrent transfers
        from_user = User.objects.select_for_update().get(id=from_user_id)
        to_user = User.objects.select_for_update().get(id=to_user_id)
        
        if from_user.balance < amount:
            raise ValueError("Insufficient funds")
        
        from_user.balance -= amount
        to_user.balance += amount
        
        # Create transfer record
        Transfer.objects.create(
            from_user=from_user,
            to_user=to_user,
            amount=amount
        )
        
        from_user.save()
        to_user.save()

Solution 3: Avoiding Deadlocks with Consistent Lock Ordering

Deadlocks occur when transactions acquire locks in different orders. Always lock records in a consistent sequence:

def process_order(user_id, product_id, quantity):
    with transaction.atomic():
        # Always lock user first, then product (consistent order)
        user = User.objects.select_for_update().get(id=user_id)
        product = Product.objects.select_for_update().get(id=product_id)
        
        if product.stock < quantity:
            raise ValueError("Insufficient stock")
        
        # Process order logic...
        product.stock -= quantity
        user.total_orders += 1
        
        product.save()
        user.save()

Solution 4: Timeout and Retry Logic

Sometimes you need to handle lock timeouts gracefully:

import time
from django.db import OperationalError

def update_with_retry(user_id, amount, max_retries=3):
    for attempt in range(max_retries):
        try:
            with transaction.atomic():
                user = User.objects.select_for_update(
                    nowait=True  # Don't wait for lock
                ).get(id=user_id)
                user.balance += amount
                user.save()
                return user
        except OperationalError as e:
            if "could not obtain lock" in str(e) and attempt < max_retries - 1:
                time.sleep(0.1 * (2 ** attempt))  # Exponential backoff
                continue
            raise

Conclusion

Race conditions and deadlocks are preventable with the right tools. Used select_for_update() to lock specific rows and transaction.atomic() to ensure data consistency across multiple operations.

Key takeaways:

  • Always use select_for_update() when reading data you plan to update
  • Wrap related operations in transaction.atomic() blocks
  • Maintain consistent lock ordering to prevent deadlocks
  • Implement retry logic for high-concurrency scenarios

These patterns will make your Django application robust against concurrent access issues, ensuring data integrity and preventing the mysterious bugs that keep developers up at night.