skip to content

When two Django requests register for the last seat of a workshop at the same moment and both succeed, why does it happen and how do update() and F() fix it?

level: seniorimportance: must knowfreq 50%

answer

  1. check in Python, write later
  2. lost update between read and save
  3. move the condition into the UPDATE
  4. update() returns rows matched

basics

~10 s

Both requests read seats_taken before either writes, so both pass the capacity check in Python. A conditional filter(seats_taken__lt=F("capacity")).update(seats_taken=F("seats_taken") + 1) moves check and increment into one UPDATE; a return of 0 means full.

solid answer

~40 s

The view does read-check-write in Python: it loads the `Workshop`, compares `seats_taken < capacity`, increments and calls `save()`. Two requests can both load 29 of 30, both pass the check and both save 30, so two people get the last seat and one increment is lost. `F()` turns the increment into SQL (`seats_taken = seats_taken + 1`), which fixes the lost update, but the check is still stale. The fix is a conditional `QuerySet.update()`: `Workshop.objects.filter(pk=wid, seats_taken__lt=F("capacity")).update(seats_taken=F("seats_taken") + 1)`. The database evaluates condition and increment in one statement on the locked row, and `update()` returns the number of rows matched, so `0` means the workshop was full. Wrap it and the `Registration.objects.create()` in `transaction.atomic()` so a failed insert gives the seat back.

code

python · 24 lines
python
from django.db import models, transaction
from django.db.models import F


class WorkshopFull(Exception):
    pass


class Workshop(models.Model):
    title = models.CharField(max_length=200)
    capacity = models.PositiveIntegerField()
    seats_taken = models.PositiveIntegerField(default=0)


def claim_seat(workshop_id, email):
    with transaction.atomic():
        claimed = Workshop.objects.filter(
            pk=workshop_id, seats_taken__lt=F("capacity")
        ).update(seats_taken=F("seats_taken") + 1)
        if not claimed:
            raise WorkshopFull
        return Registration.objects.create(
            workshop_id=workshop_id, email=email
        )

go deeper

for a junior

Recall that F() refers to a column's value in the database and that update() writes many rows in one statement without calling save().

for a middle

Explain the lost update: two requests read the same value before either writes, and why F() fixes the counter but not a check made in Python.

for a senior

Write the conditional update(), use its returned row count as the verdict, tie it to the registration insert with atomic(), and add a CheckConstraint backstop.

for a principal

Weigh conditional updates, row locks and constraints for every scarce-resource path, and set a team rule that decisions on shared counters happen in the database.

## The bug: a check made on a stale copy A common first version of a sign-up view in Django looks like this: ```python workshop = Workshop.objects.get(pk=workshop_id) if workshop.seats_taken < workshop.capacity: workshop.seats_taken += 1 workshop.save() Registration.objects.create(workshop=workshop, email=email) ``` Django runs in **autocommit** by default, and `save()` writes immediately; nothing is held back. Between the `SELECT` issued by `get()` and the `UPDATE` issued by `save()`, another request can run the same lines. With 29 of 30 seats taken: 1. request A loads the row: `seats_taken = 29`; 2. request B loads the row: `seats_taken = 29`; 3. both pass `29 < 30` in Python; 4. both save `seats_taken = 30` and both create a `Registration`. The result is **31 registrations for 30 seats** and a counter that is off by one. The comparison was correct for the data each request read; the data was stale by the time it was written. ## Why `F()` alone is not enough `django.db.models.F` refers to a column's value **inside the database**. Assigning `workshop.seats_taken = F("seats_taken") + 1` and calling `save()` produces `UPDATE ... SET seats_taken = seats_taken + 1`, so two concurrent increments both count and the counter no longer loses updates. But the `if` still ran on the stale Python value, so the workshop can still be overbooked; it just ends at 31 instead of 30. Since **Django 6.0**, a field assigned an `F()` expression is refreshed after `save()`: on PostgreSQL, SQLite and Oracle through `RETURNING`, on MySQL and MariaDB by deferring the field so the next access reloads it. In older versions the attribute kept holding the expression, and saving the instance again applied the increment a second time. ## The fix: put the condition into the `UPDATE` ```python from django.db import transaction from django.db.models import F with transaction.atomic(): claimed = Workshop.objects.filter( pk=workshop_id, seats_taken__lt=F("capacity") ).update(seats_taken=F("seats_taken") + 1) if not claimed: raise WorkshopFull Registration.objects.create(workshop_id=workshop_id, email=email) ``` What makes this safe: - **One statement.** The ORM emits `UPDATE workshop SET seats_taken = seats_taken + 1 WHERE id = %s AND seats_taken < capacity`. Check and increment are a single write. - **The row is locked by the write.** The second concurrent `UPDATE` on the same row waits for the first to finish. On PostgreSQL's default isolation level it then re-checks the `WHERE` clause against the committed row, sees `30 < 30` is false and matches nothing. - **`update()` returns a count.** `QuerySet.update()` returns the number of rows matched, so `claimed == 0` means "full" and `1` means "you got a seat". No extra `SELECT` is needed. - **`transaction.atomic()` ties the two writes together.** If `create()` then fails, for example on a unique constraint that stops the same email registering twice, the increment is rolled back with it. ## Reporting the outcome Catch the errors **outside** the `atomic()` block, so that the rollback has already happened when you handle them: - `WorkshopFull` (your own exception, raised when `update()` returned `0`) becomes a "no seats left" message or a waitlist offer; - `IntegrityError` from `Registration.objects.create()`, typically a unique constraint on `(workshop, email)`, means this person is already registered; the seat increment was rolled back with it, so the counter stays correct. Catching `IntegrityError` inside the block and carrying on is a mistake: Django has marked the block for rollback, so the next query in it raises `TransactionManagementError`, and on PostgreSQL the transaction is unusable anyway. ## Alternatives and backstops | Approach | What it guarantees | Cost | |---|---|---| | Conditional `update()` with `F()` | check and increment in one statement | one query; no instance loaded | | `select_for_update()` then check in Python | row locked for the whole block | lock held longer; must be inside `atomic()` | | Counting `Registration` rows before `create()` | nothing under concurrency | same race as the original | | `CheckConstraint(condition=Q(seats_taken__lte=F("capacity")), ...)` | the database rejects overbooking | turns the bug into an `IntegrityError`, not a clean "full" | The constraint is a good **backstop** alongside the conditional update, not a replacement: it catches a future code path that forgets the pattern. ## Things to remember after `update()` - Instances already loaded in memory are **not** changed by `update()`; reload with `refresh_from_db()` if you need the new count. - `update()` bypasses `save()` and the `pre_save`/`post_save` signals, and it cannot set fields on related models. - The same pattern works for stock levels, coupon redemptions and rate counters: whenever a decision depends on a value, let the database compare it in the same statement that changes it.

  • In Django, why not just use select_for_update() to lock the workshop row before checking capacity?
    It works: inside `transaction.atomic()`, `select_for_update().get(pk=...)` holds a row lock until the block ends, so the Python check is safe. It costs a longer-held lock and an extra query, and it fails if someone forgets the `atomic()` block. The conditional `update()` does the same job in one statement.
  • In Django, what does QuerySet.update() return, and can it differ from the number of rows whose values changed?
    It returns the number of rows the `WHERE` clause matched. Django documents the result as rows matched, so a row that already held the new value still counts. For the seat claim, matched is exactly what you want: 1 means the condition held.
  • In Django, how would you let a cancellation free a seat without creating a new race?
    Use the mirror update inside `atomic()` with the registration delete: `filter(pk=wid, seats_taken__gt=0).update(seats_taken=F("seats_taken") - 1)`. The condition keeps the counter from going negative even if a cancellation is processed twice.

Read-then-save is two people each glancing at a whiteboard that says one seat left and both writing zero. The conditional UPDATE is a turnstile that only turns while a seat remains: whoever pushes second finds it locked.

saying these in an interview costs you the question

  • save() is delayed until the request ends, so the check stays valid
  • Using F() in save() alone prevents overbooking
  • QuerySet.update() returns the updated Workshop instance
  • Counting registrations before create() closes the race
  • Wrapping the original read-check-save in atomic() alone makes it safe