Picture two users checking out the same product, with only one unit left in stock, at nearly the same moment. Without special handling, both requests can pass stock validation and both orders get created — even though only one item actually exists. This isn't a rare edge case; on a system with reasonable traffic, this kind of race condition can happen daily.
This article covers how I handled this in the checkout endpoint of my single-vendor-ecommerce project: not by decrementing stock immediately at checkout, but through a database-level locked reservation mechanism.
Why Reservation, Not Immediate Stock Deduction
The simplest way to prevent overselling is to decrement stock immediately when a user starts checkout. But this has a problem: checkout doesn't always end in a completed payment. Users can abandon the flow, sessions can expire, or payments can fail. If stock is already reduced at checkout time, the system has to constantly restore stock for every failure case — and each restoration is itself a place where miscounts or missed rollbacks can happen.
The approach I used separates two numbers: stock (the actual quantity on hand) and reserved_stock (the quantity currently "held" by an in-progress checkout). During checkout, only reserved_stock changes. The real stock only decreases once payment is confirmed through the Midtrans webhook.
With this separation, availability is calculated as stock - reserved_stock, not stock alone. This means items held by another in-progress checkout automatically become invisible as available stock to other users, without ever touching the real stock number until the transaction is actually finalized.
Implementation: Locking in _validate_and_reserve_stock
Here's the code from checkout.py that handles stock validation and reservation:
def _validate_and_reserve_stock(self, carts):
product_ids = carts.values_list("product__id", flat=True)
products = (
Product.objects.select_for_update()
.filter(id__in=product_ids)
.order_by("id")
)
products_map = {product.id: product for product in products}
for cart in carts:
product = products_map[cart.product.id]
available_stock = product.stock - product.reserved_stock
if available_stock < cart.qty:
raise serializers.ValidationError(
{"detail": f"Stok {product.name} tidak cukup"}
)
product.reserved_stock += cart.qty
Product.objects.bulk_update(products, ["reserved_stock"])
There are three things I made sure of here:
-
select_for_update()locks the relevant product rows at the database level. Until this transaction finishes, any other checkout request touching the same product has to wait — it can't read stale data and slip past stock validation. -
.order_by("id")guarantees a consistent lock acquisition order. If two checkouts both reserve products A and B but in different order, without a consistentorder_by, they could end up waiting on each other's locks — a deadlock. -
A single
bulk_updateat the end, instead of callingsave()per product inside the loop, so the write to the database happens as one query instead of N separate ones.
Availability is calculated from product.stock - product.reserved_stock, consistent with the separation covered in the previous section. If stock isn't sufficient, the request is rejected with a ValidationError before a single row is modified.
Why It's Wrapped in a Single transaction.atomic()
Stock reservation doesn't stand alone — it's part of a larger flow in CheckoutService.execute():
def execute(self):
with transaction.atomic():
checkout = CheckoutSession.objects.create(
user=self.user,
cart_ids=self.cart_ids,
destination=self.destination,
store=self.store,
expires_at=timezone.now() + timedelta(minutes=10),
)
carts = get_valid_carts(self.user, checkout.cart_ids)
self._validate_and_reserve_stock(carts)
service = OrderService(checkout, carts)
order = service.execute()
checkout.order = order
checkout.save(update_fields=["order"])
return checkout
Notice that _validate_and_reserve_stock runs before OrderService.execute() finishes creating the Order and OrderItem records. That means reserved_stock is already incremented before there's any guarantee the order itself will be created successfully.
At first glance this looks risky — what happens if OrderService.execute() fails midway? The answer is in the transaction.atomic() scope wrapping everything. Django doesn't commit anything to the database until this block completes without error. If OrderService.execute() raises any exception, every change inside the block — including the reserved_stock increment from bulk_update — gets rolled back together.
In other words, the step ordering doesn't create a gap, because the unit of consistency isn't per-step, it's per-transaction. Stock reservation and order creation succeed or fail as a single unit.
Known Limitations
Locking with select_for_update() solves the race condition at checkout creation, but there's one thing it doesn't solve: releasing the reservation when a checkout is abandoned.
Every CheckoutSession has an expires_at, set to 10 minutes from creation. If a user starts checkout but never proceeds to payment — whether they change their mind, close the app, or lose connection — the reserved_stock incremented for that checkout stays held even after the session expires. There's currently no process that automatically detects expired checkouts and releases their reserved_stock.
The impact: if enough checkouts get abandoned on a product with limited stock, available_stock (stock - reserved_stock) can end up lower than what's actually available, even though no payment is in progress for those reservations.
This isn't a flaw in the locking mechanism — the locking itself solves exactly what it was designed to solve. It's a separate gap: the reservation lifecycle doesn't yet have an automatic cleanup process. The next step I'm planning is a scheduled task (Celery beat is the likely candidate) that periodically scans for CheckoutSession records past their expires_at without a paid order, and releases their associated reserved_stock.
End-to-End Flow
Here's the complete flow of CheckoutView.post(), from incoming request to returned response — including the points where the process can fail and how rollback works inside transaction.atomic()
Conclusion
Handling race conditions in checkout isn't just about adding select_for_update() in one place. What matters more is identifying the right unit of consistency: in this case, stock reservation and order creation need to succeed or fail together, not as two separate operations.
Locking solves the stale-read problem when checkouts happen concurrently. The right transaction.atomic() scope makes sure the step ordering doesn't open a new gap. But neither of those automatically solves the full lifecycle of a reservation — including when it should be released if it's never used. Recognizing that boundary, and knowing what the next step is, matters just as much as writing the locking itself.

Top comments (0)