python 92 lines · 3 tabs

Prevent Duplicate Order Submissions in Django with select_for_update Row Locking

Shared by codesnips Aug 2026
3 tabs
import uuid
from django.db import models


class Cart(models.Model):
    OPEN = "open"
    PROCESSING = "processing"
    PAID = "paid"
    STATUS_CHOICES = [(OPEN, "Open"), (PROCESSING, "Processing"), (PAID, "Paid")]

    user = models.ForeignKey("auth.User", on_delete=models.CASCADE)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=OPEN)
    idempotency_key = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
    total_cents = models.PositiveIntegerField(default=0)

    def is_open(self):
        return self.status == self.OPEN


class Order(models.Model):
    cart = models.OneToOneField(Cart, on_delete=models.PROTECT, related_name="order")
    charge_id = models.CharField(max_length=64)
    amount_cents = models.PositiveIntegerField()
    created_at = models.DateTimeField(auto_now_add=True)
3 files · python Explain with highlit

This snippet shows how a checkout endpoint in Django is protected against double-submission, the classic race where an impatient user double-clicks or a mobile client retries, and two requests try to charge and fulfill the same cart at once. The core technique is a database row lock acquired with select_for_update() inside transaction.atomic(), so that concurrent requests serialize on the cart row rather than racing through the same code path.

In models.py, the Cart carries a status field and a unique idempotency_key, while Order has a one-to-one link back to the cart. The uniqueness of cart on Order is a second line of defense: even if application logic fails, the database will reject a second order for the same cart. The Cart.is_open() helper centralizes the state check the view relies on.

In checkout.py, place_order wraps everything in transaction.atomic() and immediately calls Cart.objects.select_for_update().get(...). This issues a SELECT ... FOR UPDATE, taking a write lock on that cart row; a second concurrent transaction blocks on that same row until the first commits. Once the lock is held, the code re-reads status inside the critical section — a check performed before the lock would be worthless, since the value could change between the read and the write. If the cart is no longer open, AlreadyProcessed is raised and the existing order is returned, making the operation effectively idempotent. Only after charging does it flip the status and create the Order; the IntegrityError guard turns a lost race on the unique constraint into the same idempotent result.

In views.py, CheckoutView.post translates these outcomes into HTTP: a fresh order yields 201, an already-processed cart yields 200 with the same order id, and a missing cart yields 404. Because the whole body runs in one atomic block, a failure anywhere rolls back the status change and the order together, never leaving a cart half-fulfilled.

The main trade-off is that select_for_update requires a real transaction and a backend that supports row locks such as PostgreSQL; it does not work with sqlite. Locks are held until commit, so the critical section should stay short and avoid slow external calls where possible.


Related snips

Share this code

Here's the card — post it anywhere.

Prevent Duplicate Order Submissions in Django with select_for_update Row Locking — share card
Link copied