skip to content

In Django, why can two flash-sale checkouts that lock several product rows with select_for_update() deadlock, and how do you prevent and handle it?

level: seniorimportance: should knowfreq 44%

answer

  1. A then B versus B then A
  2. same order for everyone
  3. one query, ordered by pk
  4. retry the whole block

basics

~20 s

Checkouts locking the same product rows in opposite orders wait on each other until the database aborts one. Lock all rows in one select_for_update() query ordered by primary key, and retry the whole atomic block on that error.

solid answer

~40 s

A basket checkout that locks products one by one in basket order can deadlock: buyer 1 locks product A then waits for B, buyer 2 locks B then waits for A. PostgreSQL detects the cycle and aborts one transaction; Django surfaces it as a `DatabaseError` subclass (`OperationalError`) from the query. Prevention in Django terms: lock every row you need in **one** query — `Product.objects.select_for_update().filter(pk__in=ids).order_by("pk")` — so every checkout requests locks in the same order; lock before doing other work; keep the block short. Handling: the transaction is dead after the error, so the `try` goes **around** `transaction.atomic()` and the retry re-runs the whole block a small number of times. Nothing with side effects — emails, task enqueues — should run inside the retried block except through `on_commit`.

code

python · 16 lines
python
import random
import time

from django.db import OperationalError, transaction


def checkout(basket, attempts=3):
    for attempt in range(1, attempts + 1):
        try:
            with transaction.atomic():
                reserve_stock(basket)  # one ordered select_for_update() query
            return
        except OperationalError:
            if attempt == attempts:
                raise
            time.sleep(random.uniform(0.01, 0.05))

go deeper

for a junior

Remember that locking the same rows in different orders can deadlock, and that ordering the locking query by pk avoids it.

for a middle

Explain the wait-for cycle in a per-item locking loop and why one ordered select_for_update() query prevents it.

for a senior

Design the retry around the whole atomic block, keep side effects out of it, and trace lock order through signals and joins.

for a principal

Decide when hot rows should be redesigned so that checkouts do not need multi-row locks at all.

## How the deadlock forms A **deadlock** is two transactions each holding a lock the other needs. With `select_for_update()` it typically comes from locking rows one at a time in an order that depends on the input: ```python with transaction.atomic(): for item in basket.items: # basket order product = Product.objects.select_for_update().get(pk=item.product_id) product.stock -= item.qty product.save(update_fields=["stock"]) ``` | Step | Checkout 1 (basket: A, B) | Checkout 2 (basket: B, A) | |---|---|---| | 1 | locks A | locks B | | 2 | waits for B | waits for A | | 3 | — deadlock — | — deadlock — | The database detects the cycle and aborts one of the transactions with an error; the other proceeds. Django wraps the driver error in a `django.db.DatabaseError` subclass — `OperationalError` with PostgreSQL's driver. ## Prevention: a consistent lock order 1. **Lock all rows in one query.** `select_for_update().filter(pk__in=ids)` takes every lock in a single statement instead of a loop. 2. **Order it deterministically.** Add `.order_by("pk")`. Rows are locked as the query reads them, so a fixed sort order means every checkout asks for A before B. 3. **Lock first, compute second.** Take the locks at the top of the block, then validate and update; locks acquired mid-way through other work are where inconsistent orders creep in. 4. **Keep the block short** — fewer, shorter lock holds mean fewer chances to collide. 5. **Lock only what you change.** Use `of=("self",)` when `select_related()` would otherwise also lock shared rows in join order. ```python with transaction.atomic(): products = { p.pk: p for p in Product.objects.select_for_update() .filter(pk__in=[i.product_id for i in basket.items]) .order_by("pk") } for item in basket.items: products[item.product_id].stock -= item.qty Product.objects.bulk_update(products.values(), ["stock"]) ``` ## Handling: retry the whole transaction Ordering removes the common cause, but other code paths — an admin action, a signal, a row updated by a plain `update()` — can still lock the same rows in another order. When the error arrives: - The transaction is **aborted**. Catching the error inside the `atomic` block and continuing fails — the same rule as for `IntegrityError`. - Put the `try` **around** `transaction.atomic()` and re-run the whole function, a small bounded number of times, ideally with a short randomised pause. - Retry only the deadlock or lock-timeout class of errors, not `IntegrityError` or programming errors. - Keep side effects out of the retried block, or register them with `transaction.on_commit()` so a rolled-back attempt emits nothing. ## Things that make it worse in Django projects - **Signals** that lock or update other rows during `save()`, silently extending the lock order. - **`ATOMIC_REQUESTS`**, which turns the whole view into one long transaction. - **Implicit joins**: `select_related()` with the default lock scope also locks joined rows, adding tables to the lock order you have to reason about. - **Loops over user-ordered input**, as in the basket example. ## Diagnosing - Look for the database's deadlock message in logs next to Django's `OperationalError` traceback; it names both statements. - Find every code path that locks the same tables and write down its lock order; the mismatched one is the culprit. - A spike of deadlocks at a sale's start usually points at per-item locking loops rather than at the database. ## Why not just use `nowait`? `nowait=True` does stop a checkout from ever *waiting*, so no wait-for cycle can form — but every contended checkout now fails instead of queueing, which during a sale means a flood of "try again" errors. It is a valid choice for a fail-fast UX, not a substitute for a consistent lock order.

  • Why does .order_by("pk") on the select_for_update() query help?
    The database takes row locks as it reads the rows, so a deterministic sort makes every transaction request the same rows in the same sequence. Two checkouts can then only queue behind each other, never wait on each other in a cycle.
  • Could you catch the deadlock error inside the atomic block and just re-run the query?
    No. The database has aborted the transaction, so every further statement in it fails until it is rolled back, and Django rolls it back only when the block exits. The retry must wrap `transaction.atomic()` and re-run all the work.

saying these in an interview costs you the question

  • Wrapping each item's lock in its own nested atomic block prevents deadlocks
  • Catch the deadlock inside the atomic block and re-run the query
  • Locking rows one by one in a loop is fine if each lock is quick
  • Retry any DatabaseError, including IntegrityError, the same way
  • Django itself detects deadlocks and retries automatically