Django cookbook · Intermediate

Update stock safely with transactions, F() and select_for_update

Two customers buy the last item at the same time and both orders succeed.

Create a free account to save this page, take notes and track your progress. Sign in

Approach

Do the check and the update in the database, atomically. A conditional UPDATE ... SET stock = stock - 1 WHERE stock >= 1 (with F()) is the simplest; when you need the row in Python, lock it with select_for_update() inside transaction.atomic().

Solution

from django.db import transaction
from django.db.models import F

from shop.models import Order, Product


class OutOfStock(Exception):
    pass


def reserve(product_id: int, quantity: int) -> None:
    updated = Product.objects.filter(id=product_id, stock__gte=quantity).update(stock=F("stock") - quantity)
    if updated == 0:
        raise OutOfStock(product_id)


@transaction.atomic
def place_order(user, product_id: int, quantity: int) -> Order:
    product = Product.objects.select_for_update().get(id=product_id)   # row locked until commit
    if product.stock < quantity:
        raise OutOfStock(product_id)
    product.stock -= quantity
    product.save(update_fields=["stock"])
    return Order.objects.create(customer=user, product=product, quantity=quantity, unit_price=product.price)

Pitfalls

  • Reading product.stock, checking it in Python and saving later without a lock or conditional update.
  • Calling external services (payments, email) inside the atomic block while rows are locked.
  • select_for_update() outside a transaction, which raises an error.

Variations

  • Add a CheckConstraint(condition=Q(stock__gte=0), name="stock_not_negative") as a safety net.
  • Use select_for_update(skip_locked=True) for job-queue style tables.

Further reading: select_for_update

Reference: https://docs.djangoproject.com/en/6.0/ref/models/querysets/#select-for-update

More Django recipes

Was this page helpful?