Transactions, Locking & Concurrency in Code · Isolation Levels and Anomalies

Lost Updates: The Read-Modify-Write Bug

Two buyers, one item left, two orders confirmed — and four correct ways to fix it.

Scenario

Example app: an online shop runs a flash sale of 500 sneakers at 10:00. About 3,000 checkout requests per second hit POST /checkout in the first minute.

What you see in production

The sale "sells" 537 pairs. Stock in the database ends at 0, never negative, and nothing errored — yet 37 customers have confirmed orders the warehouse can't fill. In quiet hours the same code is always correct.

The naive code

Read the stock into the application, check it, write the new value back:

def checkout(product_id, qty):
    with transaction.atomic():
        product = Product.objects.get(pk=product_id)       # SELECT stock …
        if product.stock < qty:
            raise SoldOut()
        product.stock -= qty                               # computed in Python
        product.save(update_fields=["stock"])              # UPDATE … SET stock = <old - qty>
        Order.objects.create(product=product, qty=qty)

Reproduce it

Two requests for the last pair, interleaved the way they are under load:

Two requests, one pair left: both succeedTry it yourself
db = {"stock": 1}
orders = []

def checkout(name):
    stock = db["stock"]                          # SELECT stock FROM products …
    yield f"{name}: read stock = {stock}"
    if stock < 1:
        yield f"{name}: sold out"; return
    db["stock"] = stock - 1                      # UPDATE products SET stock = <computed>
    orders.append(name)
    yield f"{name}: wrote stock = {stock - 1}, order confirmed"

a, b = checkout("A"), checkout("B")
for step in (a, b, a, b):                        # both read before either writes
    print(next(step))
print(f"stock = {db['stock']}, orders confirmed = {len(orders)}  <- one pair, two orders")

Output

A: read stock = 1
B: read stock = 1
A: wrote stock = 0, order confirmed
B: wrote stock = 0, order confirmed
stock = 0, orders confirmed = 2  <- one pair, two orders

The same with an atomic conditional update, run on a real SQLite database (the tutorial sample shop — product 6 is a 4K monitor with stock 8):

Atomic conditional update: the database decidesTry it yourself
UPDATE products SET stock = 1 WHERE product_id = 6;          -- one left
UPDATE products SET stock = stock - 1 WHERE product_id = 6 AND stock >= 1;
SELECT changes() AS request_a_rows;                           -- 1: A got it
UPDATE products SET stock = stock - 1 WHERE product_id = 6 AND stock >= 1;
SELECT changes() AS request_b_rows;                           -- 0: B is told "sold out"
SELECT stock FROM products WHERE product_id = 6;

Output

request_a_rows
1

request_b_rows
0

stock
0

Root cause

The check and the write are separate steps on a stale copy. Between SELECT and UPDATE, another transaction can change the row; the second writer overwrites the first's result with a value computed from old data — a lost update. Neither Read Committed (Postgres, MySQL) nor MySQL's Repeatable Read prevents it, because the SELECT doesn't lock anything. Postgres Repeatable Read does detect it — the second UPDATE fails with a serialization error that you must retry.

The fix

Four correct options, from simplest to most flexible:

  1. Atomic conditional update — the database does the check and the write in one statement.
  2. Pessimistic lock — SELECT … FOR UPDATE before reading (lesson).
  3. Optimistic version check — write only if the version hasn't changed (lesson).
  4. Serializable/Repeatable Read + retry (Postgres) — let the database abort the loser.

The atomic update in each stack:

with transaction.atomic():
    updated = (Product.objects.filter(pk=product_id, stock__gte=qty)
               .update(stock=F("stock") - qty))          # one UPDATE … WHERE stock >= qty
    if updated == 0:
        raise SoldOut()
    Order.objects.create(product_id=product_id, qty=qty)

Trade-offs

  • Atomic updates are the fastest and simplest, but only work when the rule fits in a WHERE clause on one row.
  • Locks handle multi-step logic but serialize requests for the same row (a hot row during a flash sale).
  • Optimistic checks avoid blocking but cause retries under heavy contention.
  • A CHECK (stock >= 0) constraint is a cheap last line of defence: a bug becomes an error instead of an oversell.

Detect it in production

  • Invariant checks: SELECT product_id FROM products WHERE stock < 0; and "orders per product > initial stock".
  • Load tests that fire concurrent requests at the same item — single-user tests never find this.
  • Code review: any ORM pattern of load → modify field → save on a shared counter or balance.

Key points

  • Read-modify-write in application code loses updates under concurrency.
  • Prefer one atomic UPDATE … SET x = x - n WHERE x >= n and check the affected rows.
  • Use locks or version checks when the logic can't fit in one statement.
  • Add CHECK constraints and concurrent load tests.

Practice questions

Two checkouts for the last item both succeed and stock ends at 0. What happened?

Answer

Both transactions read stock = 1 before either wrote. Each computed 0 in application code and wrote it back; the second write overwrote the first. That's a lost update caused by read-modify-write without locking or conditions.

How does `UPDATE products SET stock = stock - 1 WHERE id = ? AND stock >= 1` prevent overselling?

Answer

The check and decrement happen atomically inside the database. Row locking serializes concurrent updates on that row, and the second update re-evaluates stock >= 1 against the new value, matches zero rows and reports 0 affected rows — the application treats that as sold out.

Does MySQL's default REPEATABLE READ prevent lost updates in read-modify-write code?

Answer

No. The plain SELECT reads a snapshot without locking, and the later UPDATE overwrites the current row. You need an atomic update, SELECT … FOR UPDATE, or a version check.

Open in the app to mark lessons complete, take notes and track your progress.