How to Fix a Race Condition Double Charge With SELECT FOR UPDATE

Two concurrent POST /api/charge requests both read the same balance, both pass the check, and both decrement - leaving the balance negative and the user double-charged. The fix is SELECT ... FOR UPDATE, which locks the wallet row until the first transaction commits, forcing the second to wait and re-evaluate.

Backend Engineerpythonfastapipostgresql

Why the race happens

The buggy handler runs two separate database round-trips:

# Round-trip 1: read
cur.execute("SELECT balance FROM wallets WHERE user_id = %s", (user_id,))
balance = float(cur.fetchone()[0])

# Round-trip 2: write (only if balance is sufficient)
if balance < amount:
    return 400
cur.execute("UPDATE wallets SET balance = balance - %s WHERE user_id = %s",
            (amount, user_id))
conn.commit()

Between the SELECT and the UPDATE, nothing prevents another request from reading the same balance. If two $80 charges arrive concurrently against a $100 wallet, both threads read 100.00, both pass the >= 80 check, and both decrement - final balance: -60.00.

The fix: SELECT ... FOR UPDATE inside one transaction

Postgres's SELECT ... FOR UPDATE acquires a row-level lock on the matched rows. Any concurrent transaction that tries to lock the same row blocks until the first one commits or rolls back. Put the lock, the check, and the write all inside a single transaction:

conn = psycopg2.connect(DATABASE_URL)
try:
    cur = conn.cursor()
    # Locks the row. Concurrent callers block here until we COMMIT.
    cur.execute(
        "SELECT balance FROM wallets WHERE user_id = %s FOR UPDATE",
        (user_id,),
    )
    row = cur.fetchone()
    if not row:
        conn.rollback()
        raise HTTPException(status_code=404, detail="user not found")

    balance = float(row[0])
    if balance < amount:
        conn.rollback()        # release the lock on the error path
        raise HTTPException(status_code=400, detail="insufficient balance")

    cur.execute(
        "UPDATE wallets SET balance = balance - %s WHERE user_id = %s",
        (amount, user_id),
    )
    conn.commit()              # lock releases here
    return {"user_id": user_id, "charged": amount, "new_balance": balance - amount}
except HTTPException:
    raise
except Exception as e:
    conn.rollback()
    raise HTTPException(status_code=500, detail=str(e))
finally:
    conn.close()

The second concurrent request blocks at the FOR UPDATE line, then after the first commits it reads the decremented balance (20.00), fails the check, and returns 400. No double charge.

Testing concurrent requests

Fire several requests in parallel and inspect the final balance:

for i in 1 2 3 4 5; do
  (curl -s -X POST -H 'Content-Type: application/json' \
      -d '{"user_id":1,"amount":80}' http://localhost:8000/api/charge) &
done
wait

curl http://localhost:8000/api/balance/1
# should be 20.00, not -60.00

When to use alternatives

SELECT ... FOR UPDATE is the right first choice for row-level concurrency in Postgres. It serializes competing transactions without application-side coordination. Heavier alternatives exist for higher throughput or cross-service scenarios:

Start with FOR UPDATE; reach for a distributed lock only when the database cannot be the coordinator.

Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.

Try it in a workspace →

What you'll practice

FAQ

What is a race condition double charge?

Two concurrent charge requests both read the same account balance before either one updates it. Both pass the balance check and both decrement, leaving the balance lower than intended - often negative. The root cause is a read-then-write without any locking between the two steps.

How does SELECT FOR UPDATE prevent a double charge?

SELECT ... FOR UPDATE locks the matched row(s) for the duration of the enclosing transaction. A second concurrent transaction that tries to lock the same row blocks until the first commits or rolls back. The second then reads the updated balance, fails the check, and returns an error - so only one charge succeeds.

What is the difference between SELECT FOR UPDATE and optimistic locking?

SELECT FOR UPDATE holds a pessimistic row lock - concurrent writers queue up and wait. Optimistic locking stores a version column and rejects the UPDATE if the version changed since the read, returning an error that the caller retries. FOR UPDATE is simpler when contention is low to medium; optimistic locking avoids lock waits under very high concurrency.

Keep learning

Return the Correct HTTP Status CodeBackend projectRepair Broken API PaginationBackend projectAdd CORS Middleware to an Express APIBackend projectBackend roadmapStep by step to hiredBackend interview questionsSTAR answersAll Backend projectsProjects hub

Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.

Open the workspace →