How to Prevent Race Conditions in Django: A Practical Guide to Database Transactions and Row Locks

How to Prevent Race Conditions in Django: A Practical Guide to Database Transactions and Row Locks


Introduction


Imagine you have enough credit left to generate exactly one more image in an AI app. You submit a request in one browser tab, then submit another in a second tab before the first finishes. The app accepts both requests, and each passes the credit checkβ€”yet your balance could only pay for one. This is a classic race condition.


For developers, this raises two critical questions:


  • How can both requests pass the check when there's only enough credit for one?
  • How do you prevent them from spending the same credit?

In this guide, we'll build a small Django credit system to explore those questions. We'll reproduce the bug, fix it with database transactions and row locks, and test what happens when two requests compete for the last credit.


What We'll Cover


  • Concurrency, race conditions, and the credit check
  • How to set up the Django project
  • How to reproduce the race condition
  • How to protect the balance
  • How to accept and test image requests
  • Common mistakes and next steps

Who This Guide Is For


This guide is for developers who understand basic Django but are new to concurrency. Familiarity with models, migrations, and views will help you follow the examples.


You'll need:


  • Python 3.12
  • Docker with Compose
  • Django 5.2
  • Django REST Framework 3.16
  • PostgreSQL 17 (SQLite doesn't implement the row locks we'll use)

I'll explain the concurrency concepts before we apply them to the code. Image generation will be simulated throughout the tutorial.


Understanding Concurrency and Race Conditions


Concurrency is when multiple tasks make progress at the same time. In a web application, this commonly happens when two users (or the same user in two tabs) send requests that overlap.


A race condition occurs when the outcome depends on the unpredictable timing of these concurrent operations. In our credit example, both requests read the same balance before either writes an update, so each believes there's enough credit. The balance is then decremented twice, potentially going negative.


The core issue is that the check (read) and the spend (write) are not atomic. To prevent this, we need to ensure that only one request can read and update the balance at a time.


Setting Up the Django Project


Let's create a minimal Django project with a Credit model and a view that uses a credit to generate an image.


First, set up a virtual environment and install dependencies:


python -m venv venv
source venv/bin/activate
pip install django==5.2 djangorestframework==3.16 psycopg2-binary

Create a new Django project and app:


django-admin startproject creditapp
cd creditapp
python manage.py startapp credits

Add restframework and credits to INSTALLEDAPPS in settings.py, and configure the database to use PostgreSQL:


DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'creditdb',
        'USER': 'postgres',
        'PASSWORD': 'postgres',
        'HOST': 'db',
        'PORT': '5432',
    }
}

We'll use Docker Compose to run PostgreSQL. Create a docker-compose.yml:


version: '3.8'
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: creditdb
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"

Start the database:


docker-compose up -d

Now, define the Credit model in credits/models.py:


from django.db import models

class Credit(models.Model):
    user = models.OneToOneField('auth.User', on_delete=models.CASCADE)
    balance = models.IntegerField(default=0)

Run migrations:


python manage.py makemigrations
python manage.py migrate

Create a superuser and give them some credits via the shell:


python manage.py createsuperuser
python manage.py shell

from django.contrib.auth.models import User
from credits.models import Credit
user = User.objects.get(username='admin')
Credit.objects.create(user=user, balance=1)

Reproducing the Race Condition


We'll create a simple API endpoint that checks the user's credit balance and, if positive, decrements it and returns a simulated image URL. The buggy version reads the balance, checks it, then updates it without any locking.


In credits/views.py:


from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from .models import Credit

class GenerateImageView(APIView):
    def post(self, request):
        credit = Credit.objects.get(user=request.user)
        if credit.balance > 0:
            # Simulate image generation
            credit.balance -= 1
            credit.save()
            return Response({'image_url': 'https://example.com/image.png'}, status=status.HTTP_200_OK)
        return Response({'error': 'Insufficient credits'}, status=status.HTTP_402_PAYMENT_REQUIRED)

Wire up the URL in credits/urls.py:


from django.urls import path
from .views import GenerateImageView

urlpatterns = [
    path('generate/', GenerateImageView.as_view()),
]

And include it in the project's urls.py:


from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('credits.urls')),
]

Now, simulate concurrent requests. You can use a tool like curl in two terminals, or write a small Python script using requests and threading. For example:


import requests
from threading import Thread

def make_request():
    response = requests.post('http://localhost:8000/api/generate/', auth=('admin', 'password'))
    print(response.status_code, response.json())

threads = [Thread(target=make_request) for _ in range(2)]
for t in threads:
    t.start()
for t in threads:
    t.join()

Run the Django server (python manage.py runserver) and execute the script. You'll likely see both requests return 200 OK, and the balance becomes -1. The race condition is reproduced.


How to Protect the Balance


To fix the race condition, we need to make the check-and-decrement operation atomic. Django provides selectforupdate() to lock rows at the database level. This ensures that only one transaction can read and modify the row at a time.


Update the view to use selectforupdate() inside a transaction:


from django.db import transaction

class GenerateImageView(APIView):
    def post(self, request):
        with transaction.atomic():
            credit = Credit.objects.select_for_update().get(user=request.user)
            if credit.balance > 0:
                credit.balance -= 1
                credit.save()
                return Response({'image_url': 'https://example.com/image.png'}, status=status.HTTP_200_OK)
            return Response({'error': 'Insufficient credits'}, status=status.HTTP_402_PAYMENT_REQUIRED)

Now, when two requests arrive simultaneously, the first one acquires the lock, checks the balance, decrements it, and saves. The second request waits until the lock is released, then reads the updated balance. If the balance is now 0, it correctly returns an error.


Note: selectforupdate() requires a database that supports row-level locking, such as PostgreSQL. It does not work on SQLite.


How to Accept and Test Image Requests


With the fix in place, let's verify the behavior. Reset the user's balance to 1:


Credit.objects.filter(user=user).update(balance=1)

Run the concurrent request script again. This time, one request should return 200 OK and the other 402 Payment Required. The balance remains 0, and no credit is double-spent.


You can also test edge cases:


  • Multiple users with separate balances.
  • Concurrent requests from the same user across different sessions.
  • High contention scenarios with many threads.

For automated testing, you can use Django's TestCase with TransactionTestCase to handle database transactions properly. However, testing concurrency is tricky; tools like pytest-django with pytest-xdist can help simulate parallel requests.


Common Mistakes and Next Steps


Common Mistakes


  • Using selectforupdate() outside a transaction: It has no effect unless wrapped in transaction.atomic().
  • Forgetting to lock the correct rows: Ensure you lock the row that contains the balance.
  • Using SQLite in development: SQLite doesn't support row locks, so race conditions may go unnoticed until production.
  • Overlooking other race conditions: Similar issues can occur with inventory, likes, or any check-then-act pattern.

Next Steps


  • Explore optimistic locking using version fields.
  • Use F() expressions to perform atomic updates directly in the database.
  • Consider using Redis or other distributed locks for cross-service concurrency.
  • Learn about database isolation levels and their impact on concurrency.

Conclusion


Race conditions are subtle but can lead to serious data integrity issues. By understanding concurrency and using Django's selectforupdate() within transactions, you can protect your application's critical sections. Always test under concurrent load and choose a database that supports the locking mechanisms you need.


Happy codingβ€”and may your credits always balance!

via FreeCodeCamp

Related