How Two-Phase Locking Keeps Concurrent Bank Transactions From Corrupting Your Data
July 22, 2026 · 12 min read · Lire en français
Contents
A customer taps "send" on a 100 transfer. In the same millisecond, on the other side of the world, a second transfer lands on the same account. Both read the balance, both add their amount, both write back. If the database lets those two operations interleave naively, one of the two payments is silently erased. At the scale of a payment processor that settles millions of transactions an hour, that is not a bug, it is a financial hole.
This is the sharp end of a deep idea: concurrency control. This article grew out of a technical interview with Djamo, a mobile-first bank, where exactly this class of question came up: how do you keep a ledger correct when many transfers hit the same account at once? So the framing here is money, where correctness is not optional. Collaborative document editing has a similar flavor, but that is usually coordinated at the application layer by protocols like WOPI, not by the database. So we go straight to where concurrency is really solved: inside the database engine, and concretely inside PostgreSQL.
The nightmare of simultaneous access
Account X starts with a balance of 1000. Transaction T1 deposits 100. In the same instant, T2 deposits 200. Each transfer is really three steps: read the balance, compute balance + amount, write it back. Interleave them badly and watch what happens:
| Time | T1 (deposit 100) | T2 (deposit 200) | Balance in DB |
|---|---|---|---|
| t1 | read X (1000) | 1000 | |
| t2 | read X (1000) | 1000 | |
| t3 | compute 1000 + 100 | 1000 | |
| t4 | compute 1000 + 200 | 1000 | |
| t5 | write X = 1100 | 1100 | |
| t6 | write X = 1200 | 1200 |
The final balance is 1200. It should be 1300. T1's deposit evaporated because T2 read the balance before T1 wrote its result, so T2 overwrote it. This is the lost update. Neither transaction is wrong on its own. The interleaving is the bug, and no amount of careful application code fixes it, because the two requests may run on different servers with no knowledge of each other. Only the shared component underneath, the database, can arbitrate.
The abstraction: the transaction model
To reason about this without drowning in disk pages and B-trees, we use the transaction model. The system treats each piece of data as an opaque entity (a row, a page, a key) and reduces every transaction to two primitive actions: read and write. We write r1[X] for "T1 reads X" and w2[X] for "T2 writes X".
A transaction Ti is then just an ordered sequence of such operations, ending in a commit c_i or an abort a_i. Our lost update becomes a compact string:
r1[X] r2[X] w1[X] w2[X]
That is the entire problem, reduced to its skeleton. Two reads of X precede both writes, so the second write clobbers the first. Concurrency control is the discipline that decides which such interleavings are legal.
Formally, a schedule S over a set of transactions is an interleaving of all their operations that preserves the internal order of each transaction. If T1 does r1[X] before w1[X], every schedule must keep that order. What it may choose is where T2's operations slot in between.
The math: conflict-serializability
We need a precise definition of "correct", and force-brute serial execution gives us the reference point.
A serial schedule runs each transaction to completion before starting the next: T1 fully, then T2, then T3. It is trivially correct, because nothing overlaps, so no anomaly is possible. The price is zero parallelism, which no payment system can afford. So we want a schedule that runs concurrently yet behaves as if it were serial. That property is serializability, and here is how we make it checkable.
Two operations conflict when they belong to different transactions, touch the same entity, and at least one is a write. So r/w, w/r and w/w on the same entity conflict, but two reads never do. Formally, for a schedule S:
conflict(op_i, op_j) ⇔ entity(op_i) = entity(op_j)
∧ Ti ≠ Tj
∧ (op_i is a write ∨ op_j is a write)
Two schedules are conflict-equivalent when they contain the same operations and order every pair of conflicting operations the same way. A schedule is conflict-serializable when it is conflict-equivalent to some serial schedule. Non-conflicting operations may be freely reordered, so we only care about the relative order of the conflicting pairs.
The tool that decides this is the precedence graph (also called the serialization graph) SG(S):
- one node per committed transaction,
- a directed edge
Ti → Tjwhenever an operation of Ti conflicts with, and comes before, an operation of Tj in S.
Now the foundational result of the whole field:
Serializability theorem. A schedule S is conflict-serializable if and only if its precedence graph
SG(S)is acyclic.
The intuition is clean. An edge Ti → Tj means "Ti must come before Tj in any equivalent serial order". If the graph is acyclic, a topological sort of it produces exactly that serial order. If there is a cycle Ti → Tj → ... → Ti, then Ti must come strictly before itself, which is impossible, so no equivalent serial order exists. Our lost update has edges T1 → T2 (from r1[X] before w2[X]) and T2 → T1 (from r2[X] before w1[X]), forming a 2-cycle. Cycle means not serializable, which is the formal fingerprint of the bug.
Checking acyclicity of a graph is cheap. The catch is that building SG(S) requires knowing the whole schedule, and a busy database runs thousands of transactions per second with no idea what arrives next. We need a rule that guarantees acyclicity while the schedule is still being produced, without ever constructing the graph. That rule is locking.
Locks and their two modes
A lock is a claim a transaction places on an entity before touching it. Crucially there are two modes, and the distinction is what allows any parallelism at all.
- A shared lock (S), or read lock: "I am reading this, others may read it too, but nobody may write it." Many transactions can hold it on the same entity at once.
- An exclusive lock (X), or write lock: "I am writing this, nobody else may read or write it." Only one holder, and no shared lock may coexist.
This collapses into a compatibility matrix. A lock request is granted only if it is compatible with every lock already held on that entity:
| Held \ Requested | Shared (S) | Exclusive (X) |
|---|---|---|
| Shared (S) | compatible | wait |
| Exclusive (X) | wait | wait |
Two readers coexist, a writer excludes everyone. This is why read-heavy workloads scale under locking while write contention does not. An incompatible request blocks until the conflicting lock is released.
The golden rule: two-phase locking
Locks alone do not guarantee serializability. You can acquire and release them in an order that still produces the lost update. The discipline that fixes it is two-phase locking (2PL), a rule about when you may acquire and release, splitting every transaction into two phases:
- Growing phase (acquisition). The transaction may acquire locks in any order, but may not release any.
- Shrinking phase (release). The instant it releases its first lock, it flips to shrinking. From then on it may only release, never acquire.
Plot the number of locks a transaction holds over time. It rises monotonically to a peak, then falls monotonically. That peak is the lock point LP(Ti), the instant the transaction owns everything it will ever need.
locks
held | /\
| / \
| / \
| / \___
|______/ \____
+--------------------------> time
growing | lock | shrinking
| point |
Why accept a rule that clearly holds locks longer than needed? Because of the payoff:
2PL theorem. If every transaction obeys the two-phase rule, every schedule the system produces is conflict-serializable.
Here is the proof sketch, and it is worth following because it shows why the rule works. Suppose the precedence graph had an edge Ti → Tj. That edge exists because some operation of Ti conflicted with a later operation of Tj on a shared entity. For both to touch that entity in conflict, Ti must have released its lock and Tj must have acquired one afterward. Releasing means Ti was already in its shrinking phase, so LP(Ti) came before Tj's acquisition. Acquiring means Tj was still growing, so that acquisition came before LP(Tj). Chaining these:
Ti → Tj in SG(S) ⇒ LP(Ti) < LP(Tj)
Every edge in the precedence graph strictly increases the lock point. A cycle Ti → ... → Ti would therefore force LP(Ti) < LP(Ti), a contradiction. So the graph has no cycles, and by the serializability theorem the schedule is conflict-serializable. The lock points themselves give the equivalent serial order: sort the transactions by LP(Ti).
That is the whole magic. We replaced an impossible runtime graph analysis with a purely local rule, "grow then shrink", and got global serializability as a theorem.
Pessimistic versus optimistic
2PL is the flagship of the pessimistic strategy: assume conflict is likely, so lock the data before touching it and make everyone else wait. It is the right default when contention is high, exactly the case of a hot account that many transfers hit at once.
The opposite philosophy is optimistic: assume conflict is rare, let transactions run without blocking, and check for conflicts only at commit time, aborting the loser if two genuinely collided. It wins when contention is low, because it pays no locking overhead on the common path. Real databases offer both, and PostgreSQL is the clearest example, as we will see. First we have to fix a flaw in plain 2PL.
Why plain 2PL is not enough: strict 2PL
Basic 2PL guarantees serializability but still permits dirty reads and cascading aborts. Suppose T1 enters its shrinking phase, releases the lock on X, and only later aborts. In the gap, T2 read the uncommitted value of X. When T1 rolls back, T2 is built on data that never officially existed, so T2 must abort too, and anything that read from T2 aborts in turn. One failure cascades.
For a bank ledger this is intolerable, so real engines delay releasing locks:
- Strict 2PL (S2PL). Hold every exclusive lock until commit or abort. Shared locks may still drop during shrinking. This alone kills cascading aborts: nobody can read your writes until you have committed them.
- Rigorous 2PL. Hold all locks, shared and exclusive, until commit. Slightly less concurrency, simpler to reason about.
When a database says "two-phase locking", it almost always means strict or rigorous 2PL. The commit becomes the single instant where all writes become visible and all locks drop, which is exactly the "all or nothing" visibility you expect from a transaction.
The price: deadlocks
Holding locks until commit introduces the deadlock. Two transfers touching two accounts in opposite order is enough:
| Time | T1 | T2 |
|---|---|---|
| t1 | lock A (granted) | |
| t2 | lock B (granted) | |
| t3 | request B ... waits | |
| t4 | request A ... waits |
T1 holds A and wants B, T2 holds B and wants A. Neither yields. There are two responses.
Detection. The engine keeps a wait-for graph: an edge Ti → Tj means Ti is waiting on a lock Tj holds. A cycle is a deadlock. The engine picks a victim (typically the cheapest to undo), aborts it, releases its locks, and lets the survivor finish. The victim retries later. PostgreSQL does exactly this and surfaces a deadlock detected error, so your application code must be ready to retry it.
Prevention. Timestamp schemes stop cycles from forming. Give each transaction a birth timestamp TS(Ti), where older means smaller. When Ti requests a lock held by Tj:
wait-die: if TS(Ti) < TS(Tj) then Ti waits (older waits)
else Ti aborts (younger dies)
wound-wait: if TS(Ti) < TS(Tj) then Tj aborts (older wounds younger)
else Ti waits (younger waits)
Both are deadlock-free because every waiting edge points consistently from older to younger (wait-die) or younger to older (wound-wait), which can never form a cycle. Because a restarted transaction keeps its original TS, it grows older over time and eventually wins, so no transaction starves. A cruder cousin is the plain lock timeout: abort after waiting N seconds. Imprecise, but it needs neither graph nor timestamps.
PostgreSQL in the real world
PostgreSQL is worth grounding in because it offers the pessimistic and the optimistic path in one engine.
The pessimistic path. You take locks explicitly. SELECT ... FOR UPDATE acquires a row-level exclusive lock, which is precisely the tool that fixes our opening transfer bug:
BEGIN;
SELECT balance FROM accounts WHERE id = 'X' FOR UPDATE; -- exclusive row lock
UPDATE accounts SET balance = balance + 100 WHERE id = 'X';
COMMIT; -- strict-2PL: lock released only here
With FOR UPDATE, T2 cannot even read X until T1 commits, so the lost update is impossible. This is textbook strict 2PL, applied by hand where you know contention is high.
The optimistic path. For everyday reads, PostgreSQL does not take read locks at all. It uses multiversion concurrency control (MVCC): a writer creates a new version of a row rather than overwriting it, so readers see a consistent snapshot and never block on writers, and writers never block on readers. Writes still take exclusive row locks, so write-write conflicts remain serialized, but the common read path is lock-free.
At the SERIALIZABLE isolation level, PostgreSQL layers serializable snapshot isolation (SSI) on top. Rather than holding long read locks like 2PL, SSI lets transactions run optimistically, watches for the dangerous read-write patterns that would create a cycle in the precedence graph, and aborts one transaction with a serialization failure if it detects one. It is the same acyclicity guarantee from our theorem, enforced optimistically instead of pessimistically. Your code retries the aborted transaction, exactly as it retries a deadlock victim.
The four SQL isolation levels (read uncommitted, read committed, repeatable read, serializable) are really a dial for how much of this machinery is engaged. SERIALIZABLE is the only one that promises true conflict-serializable behavior, and you can reach it in PostgreSQL either pessimistically with explicit locks or optimistically with SSI. Lock-based engines like MySQL InnoDB and SQL Server lean harder on the 2PL side of that spectrum.
Conclusion
Concurrency control is invisible to the end user and absolutely central to any system where the data is money. Two-phase locking is the idea that made it tractable: it replaces an impossible runtime check, "is the precedence graph acyclic", with a simple local rule, "grow then shrink", and the theorem proves the two are equivalent. Strict 2PL hardens it against cascading aborts, deadlock detection and timestamp prevention keep it from freezing, and MVCC with SSI offers the optimistic alternative for the read-heavy common case.
Thanks to this machinery, the 100 transfer that vanished in our first example never actually vanishes. A rule you never see, enforced pessimistically by a lock or optimistically by a version check, refuses to let it.
Subscribe to future posts
Get future posts in your inbox. No spam, unsubscribe any time.
Related posts
Load Balancing Algorithms: Each One Fixes the Last One's Flaw
Spreading requests across N servers sounds like a one-liner: pick a server, send the request. Then one backend is slower, or bigger, or holds a session, and the naive choice falls apart. This walks the classic algorithms as a chain where each one exists to fix the previous one's blind spot: round robin, weighted, least connections, power of two choices, and consistent hashing, in Go, ending with the failure every tutorial forgets.
July 20, 2026
Zanzibar Demystified: How Google Answers 'Can This User Do This?'
Authorization looks like a one-line if statement until you run it ten million times a second across every product Google ships. Zanzibar is the system that made that question fast, consistent and global. This walks from the naive permission check to relationship-based access control, the tuple model, consistency with zookies, and the open-source heirs like OpenFGA you can actually run today.
July 18, 2026
Retry Storms: How Good Clients Take Down Healthy Servers
A retry looks harmless: the request failed, so try again. Multiply that by every client, add one slow dependency, and retries turn into a self-inflicted DDoS. This walks from the naive retry loop to exponential backoff, jitter, retry budgets and circuit breakers, the caller-side half of resilience that pairs with rate limiting on the server.
July 11, 2026