The code reads a balance, checks it is sufficient, subtracts an amount, and writes it back. It is obviously correct. It passes every test. It has been in production for a year.
Then two requests arrive within a few milliseconds of each other. Both read a balance of 100. Both check that 100 is enough for a withdrawal of 80. Both write back 20. Two withdrawals of 80 happened; the account lost 80.
Nothing is broken in the usual sense. The database did exactly what the isolation level permits, and the isolation level is almost certainly the default you never chose.
What changed in 2026
- Managed databases made the default less visible. Fewer teams configure isolation explicitly, so more teams inherit Read Committed without knowing what it allows.
- Serverless and edge compute raised concurrency. More concurrent connections against the same rows means anomalies that were once rare became routine.
- Optimistic concurrency spread. Version columns and compare-and-swap updates became a common alternative to raising isolation levels.
- Retry logic became expected. Serializable isolation without retry handling is a known incomplete implementation rather than a working configuration.
What each level allows
| Level |
Dirty read |
Non-repeatable read |
Phantom |
Lost update |
Write skew |
| Read Uncommitted |
Possible |
Possible |
Possible |
Possible |
Possible |
| Read Committed |
No |
Possible |
Possible |
Possible |
Possible |
| Repeatable Read |
No |
No |
Varies |
Prevented in some engines |
Possible |
| Serializable |
No |
No |
No |
No |
No |
The row worth staring at is Read Committed, because it is the default in several widely-used databases and it permits the lost update above.
The anomalies, concretely:
Non-repeatable read — you read a row, someone commits a change, you read it again in the same transaction and get different data. Calculations spanning both reads are inconsistent.
Phantom read — you run a query, someone inserts a matching row, you run it again and the result set grew. Breaks anything that counts or aggregates twice.
Lost update — two transactions read, modify, and write the same row. The second overwrites the first. This is the balance example and it is the most common real-world instance.
Write skew — the subtle one. Two transactions read overlapping data, check a constraint that holds for each individually, and write to different rows. Neither conflicts directly; the combined result violates the invariant. The classic case is two doctors both going off-call because each checked that at least one other doctor was on call.
Fixing it without going serializable
Raising the global isolation level is the blunt instrument. It works and it slows everything down to solve a problem that usually exists in a handful of transactions.
Explicit row locks. SELECT ... FOR UPDATE takes a lock on the rows you read, so a concurrent transaction blocks until you commit. This solves lost updates directly and targets only the transaction that needs it. The cost is contention — everyone touching those rows serialises — so keep the transaction short and lock in a consistent order across your codebase to avoid deadlocks.
Atomic operations. Instead of read-modify-write, express the change as a single statement: UPDATE accounts SET balance = balance - 80 WHERE id = ? AND balance >= 80. The database evaluates and applies atomically, and the affected-row count tells you whether it succeeded. This is the cleanest fix when the operation can be expressed that way, and it needs no locks or retries.
Optimistic concurrency. Add a version column. Read it, and on write require the version to be unchanged: UPDATE ... WHERE id = ? AND version = ?. Zero rows affected means someone else got there first, and you retry. Excellent under low contention, wasteful under high.
Serializable, where the invariant genuinely spans rows. Write skew is the case that locks do not fix, because there is no single row to lock. When correctness depends on a condition across a set, serializable is the honest answer — paired with retry handling, because it works by aborting transactions that would violate serial ordering.
Common mistakes
- Assuming a transaction is enough. A transaction gives atomicity. Isolation is a separate setting and a separate guarantee.
- Read-modify-write without a lock or version check. The single most common concurrency bug in application code.
- Serializable without retry logic. Transactions abort by design; unhandled, users see errors.
- Raising isolation globally for one endpoint. Everything pays; set it per transaction.
- Long transactions. Holding locks across a network call or a slow computation converts a correctness fix into an availability problem.
- Inconsistent lock ordering. Two transactions locking A-then-B and B-then-A deadlock.
- Testing only single-user. These bugs are invisible without concurrency in the test.
FAQ
Which level should I default to?
Read Committed is a reasonable default for most applications, with explicit locks or atomic updates on the specific transactions that need more. Raising the global default is rarely the right first move — it slows every query to fix a few.
Does Repeatable Read prevent lost updates?
It depends on the engine, which is exactly why this is confusing. Some detect the conflict and abort; others allow it. Check your database's documented behaviour rather than the standard's name, because implementations diverge meaningfully here.
Do read replicas change this?
They add their own wrinkle — a replica may lag, so a read-after-write against a replica can return stale data regardless of isolation level. That is a replication concern layered on top of isolation. See read replicas explained.
What about NoSQL databases?
Guarantees vary widely; many offer per-document atomicity without multi-document transactions. The anomalies are the same, and the tools available to prevent them differ. CAP theorem explained covers the tradeoffs behind those choices.
Where to go next
For the transaction guarantees isolation sits inside, read ACID transactions explained. For the concurrency-control mechanism underneath most of this, MVCC explained, and for the durability half of the picture, write-ahead logging.