Exercise 02 of 3/Foundations/1 hour

The lost update SQL will not stop for you

The same bank from the concurrency track, except now the balance lives in Postgres and every transfer is a proper ACID transaction. Money still disappears. The database is not broken and neither is your SQL — the two of them simply never agreed on what you meant.

In the concurrency track, a bank lost money because balance -= amount was three CPU operations and two threads interleaved them. The fix was a mutex.

Now the balance lives in Postgres, every transfer runs inside BEGIN/COMMIT, and the whole thing is ACID. The bug is back, unchanged.

That surprises people, because "transaction" sounds like it should mean "mutex". It does not, and the gap between those two ideas is one of the most expensive misunderstandings in application development.

Write the transfer the way applications actually write it

Set this up first

Same containers as exercise 01 — if they are still running, skip to the table.

docker run --rm -d --name iso-pg -e POSTGRES_PASSWORD=secret -p 5440:5432 postgres:16
docker run --rm -d --name iso-my -e MYSQL_ROOT_PASSWORD=secret -p 3307:3306 mysql:8

You need two terminals, each holding its own connection:

docker exec -it iso-pg psql -U postgres          # PostgreSQL
docker exec -it iso-my mysql -uroot -psecret     # MySQL

Remember that MySQL needs VARCHAR(64) where Postgres takes TEXT for a keyed column, and START TRANSACTION where Postgres takes BEGIN. Tear down with docker rm -f iso-pg iso-my.

Do this

Implement a withdrawal the normal way: SELECT the balance, compute the new one in your application, UPDATE it back — all inside one transaction.

The pattern under test, as it appears in roughly every codebase:

with conn.transaction():
    row = conn.execute("SELECT balance FROM accounts WHERE id = 'alice'").fetchone()
    new_balance = row[0] - 100            # the decision happens here, in Python
    conn.execute("UPDATE accounts SET balance = %s WHERE id = 'alice'", (new_balance,))

This is a proper ACID transaction. Which of A, C, I or D is about to fail you?

AnswerCommit to one first

None of them — and that is the point.

The transaction is atomic, consistent, isolated at the level you asked for, and durable. Every guarantee holds. The problem is that none of those letters promises mutual exclusion, which is what you actually needed.

Lose an update

Do this

Run that read-modify-write in two concurrent sessions at READ COMMITTED, interleaved so both read before either writes. Alice starts at 1000 and both withdraw 100.

Interleave two sessions:

-- A                                  -- B
BEGIN;                                BEGIN;
SELECT balance FROM accounts
  WHERE id='alice';   -- 1000
                                      SELECT balance FROM accounts
                                        WHERE id='alice';   -- 1000
UPDATE accounts SET balance=900
  WHERE id='alice';
COMMIT;
                                      UPDATE accounts SET balance=900
                                        WHERE id='alice';
                                      COMMIT;
 
SELECT balance FROM accounts WHERE id='alice';   -- 900

Two withdrawals of 100 from 1000, and the balance is 900. B's write did not fail, was not blocked, and was not warned about. It overwrote a value it had never seen with a value computed from a stale one.

This is a lost update, and it is not in the ANSI table from exercise 01 — the standard describes it only obliquely, and READ COMMITTED permits it outright. Note carefully that B never read uncommitted data and never saw a value change mid-transaction. Both of the anomalies you have learned so far are absent. This is a third thing.

Work out where the decision happened

Who computed the number 900 — your application, or the database?

AnswerCommit to one first

Your application.

Postgres received a command to store a literal, and it stored that literal faithfully. It had no way to know the value was computed from a balance that had since changed, because you never told it that.

This is check-then-act across a network boundary. It is the same bug as the bank's overdraft check, and the transaction boundary is not the critical section you assumed it was.

Hint 1Where does the decision actually happen?

Write out which participant computes 900. It is not the database. Postgres received a command to store a literal, and it stored it faithfully.

Hint 2This is check-then-act, again

You have seen this exact bug in the concurrency track: a value is read, a decision is made outside the guarded region, and acted on later. The transaction boundary is not the guarded region you assumed it was.

Fix it four ways and rank them

Do this

Fix it with an atomic UPDATE, with SELECT ... FOR UPDATE, with a version column and a retry loop, and by raising the isolation level. Get all four working, then decide when each is wrong.

Solution — four fixes, in the order you should reach for themTry it first

1. Make the database do the arithmetic (best, when you can)

UPDATE accounts SET balance = balance - 100 WHERE id = 'alice' AND balance >= 100;

Read and write are now one statement, and a row-level write lock is held for its duration — the second session blocks until the first commits, then re-reads and computes from the current value. Check the affected row count: zero means the balance >= 100 guard rejected it, which is your insufficient-funds path, expressed without a round trip.

This is the right answer whenever the new value is a function of the old one that SQL can express. It is not always available, which is what the other three are for.

2. SELECT ... FOR UPDATE (when the decision must happen in your code)

BEGIN;
SELECT balance FROM accounts WHERE id = 'alice' FOR UPDATE;  -- B blocks here
-- ... arbitrary application logic ...
UPDATE accounts SET balance = 900 WHERE id = 'alice';
COMMIT;

FOR UPDATE takes the write lock at read time, so the read-modify-write becomes genuinely serial. This is pessimistic locking and it is the only one of the four that works when the modification is not expressible in SQL — parsing a JSONB document, calling a pricing service, applying a rule engine.

Costs: contention (writers queue), and it reintroduces exactly the deadlock from concurrency exercise 02 the moment one transaction locks two rows — so the same total-ordering discipline applies, and ORDER BY id on a multi-row FOR UPDATE is not a style preference.

3. A version column, and a retry (optimistic)

ALTER TABLE accounts ADD COLUMN version INT NOT NULL DEFAULT 0;
 
-- read version 7, compute, then:
UPDATE accounts SET balance = 900, version = 8
 WHERE id = 'alice' AND version = 7;
-- 0 rows affected  =>  somebody else won. re-read and redo.

Nothing is locked; the write simply refuses to apply if the row moved underneath it. Correct at every isolation level including READ UNCOMMITTED, because it does not depend on isolation at all — the guarantee is in the predicate.

This is what every ORM's @Version does (Hibernate, JPA, ActiveRecord's lock_version), what HTTP If-Match and ETags are, and what conditional writes are in DynamoDB and Cosmos. Cost: you must actually write the retry loop, with a bound. Under heavy contention on one row it degrades badly, because every loser redoes its work. Optimistic concurrency is the right default for low-contention rows and the wrong one for a hot counter.

4. Raise the isolation level (correct, and the least useful)

BEGIN ISOLATION LEVEL REPEATABLE READ;

Postgres's REPEATABLE READ detects that B is writing a row whose version changed since B's snapshot began, and aborts it:

ERROR:  could not serialize access due to concurrent update

Correct. But note what you signed up for: the error arrives at UPDATE time, after your work is done, and you must retry — which means you have taken on fix 3's obligation without fix 3's explicitness, and a SQLSTATE 40001 handler is now load-bearing infrastructure. MySQL's REPEATABLE READ does not do this; it silently allows the lost update, so this fix is not portable.

Ranking

Prefer 1 when SQL can express the change. Use 2 when it cannot. Use 3 when the row is low-contention or the write crosses a network boundary. Reach for 4 only when you are raising the level for other reasons anyway — and never without a retry handler.

None of them is "use a transaction". BEGIN/COMMIT gives you atomicity and a consistent read snapshot. It does not give you mutual exclusion, and the lost update is a mutual-exclusion bug wearing a transaction costume.

Test the fixes against a harder case

The new value is a JSON document you must parse, mutate and write back. Which fixes survive?

AnswerCommit to one first

Only SELECT ... FOR UPDATE and the version column.

The atomic UPDATE is gone immediately — SQL cannot express the change. That is the moment pessimistic locking stops being a fallback and becomes the design.

You move to DynamoDB. Which fix travels with you?

AnswerCommit to one first

The version column, and only the version column.

There is no FOR UPDATE and no isolation level to raise. Conditional writes are the entire concurrency story — which is a reasonable argument for defaulting to optimistic concurrency in SQL too. It is the one technique that works everywhere.

The same fix in three ecosystems

PostgreSQL

READ COMMITTED permits the lost update. REPEATABLE READ turns it into a 40001 serialization failure you must retry — genuinely useful, and the reason Postgres applications that raise their level need a retry wrapper around every transaction. FOR UPDATE blocks; FOR UPDATE NOWAIT fails fast; FOR UPDATE SKIP LOCKED is how you build a work queue in a table.

MySQL / InnoDB

Its default REPEATABLE READ sounds like fix 4 and is not: InnoDB permits the lost update at that level, because an UPDATE reads the latest committed row rather than the transaction's snapshot and overwrites it without complaint. A developer moving from Postgres to MySQL and keeping the same isolation level gets weaker guarantees than the name implies. Use fix 1, 2, or 3 here — do not use 4.

DynamoDB, Firestore, Cosmos DB

No FOR UPDATE and no isolation levels to raise, so fix 3 is the only option and the API says so: conditional writes and ConditionExpression in DynamoDB, If-Match in Cosmos, transactions with automatic retry in Firestore. Everyone writing against these has to learn optimistic concurrency, which is a reasonable argument for defaulting to it in SQL too — the version column travels everywhere, and FOR UPDATE does not.

The transferable lesson. Every one of these fixes works by making the check and the write a single indivisible unit — in one statement, behind a lock, or inside a predicate. That is the same sentence as the end of concurrency exercise 01, and it is not a coincidence: a database is a shared mutable data structure accessed concurrently, and the transaction boundary is not the critical section unless you make it one.

Before you move on

You should be able to explain each of these without looking.

  • Why the lost update is neither a dirty read nor a non-repeatable read.
  • Why BEGIN/COMMIT alone does not prevent it, in one sentence about where the decision is computed.
  • Which of the four fixes survives when the new value cannot be expressed in SQL, and why it is the only one.
  • Why fix 3 works even at READ UNCOMMITTED.
  • Why fix 4 works on Postgres and not on MySQL, at the same level name.

Go further

  • Implement the version-column fix with a bounded retry loop and a test that runs 500 concurrent withdrawals. Count the retries. That number is your contention, measured — and it is the argument for fix 1 on hot rows.
  • Move the balance into a JSONB column and try to keep fix 1. You cannot express it, which is the moment FOR UPDATE stops being a fallback and becomes the design.
  • Build a job queue with SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1, then remove SKIP LOCKED and watch every worker serialise behind the first. This is the single most useful clause in this exercise and almost nobody is taught it.
  • Do the same two-session interleaving against MySQL at REPEATABLE READ and confirm the update is lost. Keep the transcript; it is the most persuasive thing you can show a colleague who trusts level names.