Race Condition in Django with Cockroachdb
Race Condition in Django with Cockroachdb — how this specific combination creates or exposes the vulnerability
A race condition occurs when multiple processes or threads access and manipulate shared data concurrently, and the outcome depends on the non-deterministic timing of their execution. In Django using CockroachDB, this commonly arises in read–modify–write sequences that are not executed atomically at the database level.
CockroachDB is a distributed SQL database that provides strong consistency and serializable isolation by default. However, application-level logic in Django can still introduce race conditions if it performs separate read and write operations instead of a single atomic update. For example, consider a booking view that reads available seats, checks capacity, and then writes a new reservation:
def book_seat(request, show_id):
show = Show.objects.get(pk=show_id)
if show.available_seats > 0:
show.available_seats -= 1
show.save()
Reservation.objects.create(show=show, user=request.user)
return HttpResponse('Booked')
return HttpResponse('No seats', status=400)
Even though CockroachDB guarantees serializable transactions, Django’s default autocommit mode executes the get and the subsequent save as separate statements outside a single database transaction. Two concurrent requests can both read available_seats == 1, each pass the check, and both decrement and save, resulting in a double-booking (a write skew). This is a classic time-of-check-to-time-of-use (TOCTOU) pattern.
The risk is compounded by Django’s ORM caching and queryset evaluation. A queryset like Show.objects.filter(pk=show_id) may be evaluated once and reused, while another concurrent request updates the row in CockroachDB. Without an explicit database-level constraint or an atomic update, the in-memory state diverges from the committed state, leading to inconsistent inventory or authorization outcomes.
Additionally, CockroachDB’s distributed nature means writes may be replicated asynchronously at the Raft follower level before they are globally visible. If Django reads from a follower that has not yet caught up (depending on its consistency settings), it may observe stale data. This interplay between Django’s read path and CockroachDB’s replication can expose race conditions that are harder to observe in single-node databases.
Cockroachdb-Specific Remediation in Django — concrete code fixes
To eliminate race conditions, ensure that read–modify–write sequences are executed as a single atomic database operation. In Django with CockroachDB, use F() expressions and update() to perform atomic decrements, and wrap related operations in an explicit transaction with the correct isolation level.
1. Use F() expressions for atomic updates:
from django.db.models import F
from django.db import transaction
@transaction.atomic
def book_seat_atomic(request, show_id):
updated = Show.objects.filter(pk=show_id, available_seats__gt=0).update(available_seats=F('available_seats') - 1)
if updated:
Reservation.objects.create(show_id=show_id, user=request.user)
return HttpResponse('Booked')
return HttpResponse('No seats', status=400)
The update is executed as a single SQL statement in CockroachDB, and the filter ensures the condition is evaluated server-side. The transaction.atomic ensures all database operations within the block are committed only if every step succeeds.
2. For multi-step workflows, use select_for_update() to lock rows within a serializable transaction:
@transaction.atomic(isolation_level='serializable')
def book_seat_locked(request, show_id):
show = Show.objects.select_for_update().get(pk=show_id)
if show.available_seats > 0:
show.available_seats -= 1
show.save()
Reservation.objects.create(show=show, user=request.user)
return HttpResponse('Booked')
return HttpResponse('No seats', status=400)
select_for_update() issues a SELECT ... FOR UPDATE in CockroachDB, placing a lock on the row until the transaction ends, which prevents concurrent modifications. Use serializable isolation to detect serialization failures and retry if needed.
3. Leverage CockroachDB’s UPSERT behavior for idempotent inserts:
from django.db import IntegrityError
def create_reservation_safe(request, show_id):
try:
with transaction.atomic():
Show.objects.upsert(
{ 'id': show_id },
update_fields=['available_seats'],
conflict_fields=['id'],
using='default'
)
Reservation.objects.create(show_id=show_id, user=request.user)
except IntegrityError:
return HttpResponse('Conflict, please retry', status=409)
return HttpResponse('Created')
While Django does not expose a native UPSERT API, you can use on_conflict_do_nothing with QuerySet.bulk_create or raw SQL for CockroachDB’s INSERT ... ON CONFLICT DO NOTHING pattern to avoid double-creating reservations under high concurrency.
4. Validate uniqueness with database constraints:
from django.db import models class Reservation(models.Model): show = models.ForeignKey(Show, on_delete=models.CASCADE) user = models.ForeignKey('auth.User', on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) class Meta: constraints = [ models.UniqueConstraint(fields=['show', 'user'], name='unique_reservation') ]Even with application logic, enforce uniqueness at the database level so CockroachDB rejects duplicate inserts rather than relying solely on Django checks.
MiddleBrick scans can help detect patterns that suggest non-atomic updates and missing transaction boundaries in your API surface. In the Pro plan, continuous monitoring and CI/CD integration via the GitHub Action can flag risky code paths before deployment, while the CLI allows quick scans from the terminal with
middlebrick scan <url>.