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:
- API call 1 reads user balance: $100
- API call 2 reads user balance: $100
- API call 1 adds $50: $100 + $50 = $150
- API call 2 adds $30: $100 + $30 = $130
- 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.