Two-Phase Locking (2PL)
Two-phase locking is the pessimistic concurrency-control protocol that buys serializability with a single rule: once a transaction releases its first lock, it may never acquire another. Every acquisition happens before any release, and that one constraint is enough to make concurrent transactions behave as if they ran one at a time. It is the lock-based counterpart to optimistic MVCC — where MVCC runs freely and aborts conflicts at commit, 2PL grabs a lock before touching data and blocks anyone who conflicts.
Key Components
- Shared (S) lock
- A read lock: "I'm reading; others may read too." Many transactions can hold an S lock on the same item at once, because reads never conflict with reads.
- Exclusive (X) lock
- A write lock: "I'm writing; nobody else touches this." Exactly one holder, incompatible with every other lock — writes conflict with everything.
- Growing & Shrinking phases
- The two phases that name the protocol. In the growing phase locks are acquired and none are released; in the shrinking phase locks are released and none are acquired. Lock count over time forms a single mountain — up, then down, never up again.
- Lock point
- The instant of the last lock acquisition — the peak of the mountain, where the transaction holds all its locks at once, just before its first release. Ordering transactions by their lock points yields a valid serial order.
- Lock upgrade (S→X)
- A transaction holding an S lock that decides to write requests an upgrade to X. A classic deadlock source: if two transactions both hold S on a row and both upgrade, each waits for the other's S to drop.
- Strict 2PL
- The practical standard: all exclusive (write) locks are held until commit or abort. This collapses the write-lock shrinking phase to a single instant and eliminates cascading aborts.
Concrete Example
Two lock modes, one tiny compatibility matrix. The whole of locking semantics reduces to a single sentence: reads don't conflict with reads; writes conflict with everything.
| Held → / Requested ↓ | Shared (S) | Exclusive (X) |
|---|---|---|
| Shared (S) | ✅ granted | ❌ wait |
| Exclusive (X) | ❌ wait | ❌ wait |
Locks are acquired on demand by the lock manager, automatically, just before a statement touches each item: a read requests S, a write requests X (or upgrades S→X), and if a conflicting lock is held the requester blocks and waits. Consider one transaction that reads items A and B and writes item C:
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- ① read A → S(A)
SELECT balance FROM accounts WHERE id = 2; -- ② read B → S(B)
UPDATE accounts SET balance = balance - 100 WHERE id = 3; -- ③ write C → X(C) ← LOCK POINT
COMMIT; -- ④
The acquisitions S(A), S(B), X(C) form the growing phase; X(C) is the lock point — the peak where all locks are held. The four 2PL variants acquire exactly the same locks; they differ only in when those locks release. That single knob — how long you hold before releasing — trades concurrency against anomalies and deadlocks:
| Variant | Release X (write) locks | Release S (read) locks | Property |
|---|---|---|---|
| Basic 2PL | anytime in shrinking phase (even pre-commit) | anytime in shrinking phase | serializable; allows cascading aborts |
| Strict 2PL (S2PL) | at commit / abort | shrinking phase (early) | recoverable; no cascading aborts |
| Strong Strict / Rigorous (SS2PL) | at commit / abort | at commit / abort | simplest; what real systems use |
| Conservative (Static) | acquire all locks before start | acquire all upfront | deadlock-free; needs predeclared set |
Reading the same trace under each variant, written as the lock-held profile over time (each cell ① ② ③ ④ is one statement):
Basic 2PL ▁▂▃███▁▁▁ release after lock point, even pre-commit
Strict 2PL ▁▂▃███▃▃▃ S(A),S(B) early; X(C) held until COMMIT
SS2PL ▁▂▃██████ release nothing early; all drop at COMMIT
Conservative █████████ acquire S(A),S(B),X(C) all at BEGIN
Basic 2PL may release X(C) before COMMIT — if another transaction reads C in that window and this one then aborts, that is a dirty read plus a cascading abort. Strict 2PL releases the read locks early but pins X(C) to commit, so no one reads uncommitted writes. SS2PL — what real systems including Postgres use — holds everything to commit, so locks only ever grow until commit then all drop together: the simplest model to reason about. Conservative 2PL grabs the entire set at BEGIN, blocking until all are free, which makes it the only deadlock-free variant (never waits while holding a lock) at the cost of having to predeclare every lock.
Visual Model
Picture a buffet with a strict rule: once you put a plate back, you can't pick up a new one. The growing phase is collecting every dish you'll need (each one exclusive while you hold it, so others queue behind you). The lock point is the instant you set your first plate down — from then on you can only return plates. It works because everyone must collect-all-then-return, so you can line people up by when they stopped collecting; no two can each claim they went first on different dishes. Strict 2PL adds one clause: hold your dirty plates (write locks) until you've paid and left (commit), so nobody eats off your unfinished plate.
Step through the lock-count mountain below. The line rises through the growing phase (S(A), S(B), X(C)) to the labelled lock point, then falls in the shrinking phase. Watch where Basic 2PL drops the write lock early versus where Strict 2PL holds it all the way to commit.
Loading…
Deeper — Edge Cases & Gotchas
Why lock points give serializability
The payoff of the never-acquire-after-release rule is subtle but mechanical. Every transaction has a single instant — its lock point — at which it holds all of its locks simultaneously. Take any 2PL schedule and order the transactions by their lock points. That ordering is a valid serial order, because no transaction can grab a lock that conflicts with one another transaction held past its own lock point: by the time the second transaction reaches for it, the first either still holds it (so the second waits) or has released it (so the first is already strictly earlier in the order). This makes it impossible to build a conflict cycle — a situation where T1 must come before T2 on item A while T2 must come before T1 on item B. No conflict cycles means the schedule is conflict-serializable.
The S→X upgrade deadlock
The very property that lets two transactions read concurrently is what traps them on a write. Both T1 and T2 take an S lock on the same row — compatible, both granted. Then both decide to write and request an upgrade to X. T1's upgrade waits for T2's S to drop; T2's upgrade waits for T1's S to drop; neither will drop while waiting. That is a deadlock born directly from shared-lock compatibility. The standard defence is to take the exclusive intent upfront: SELECT … FOR UPDATE acquires the write-lock discipline at read time instead of optimistically taking S and gambling on a later upgrade.
T1: X(C), write C=900, release X(C) -- still NOT committed!
T2: S(C), reads C=900 -- dirty read of uncommitted value
T1: ROLLBACK -- C reverts to 1000
T2: ... already acted on 900 → must ABORT too (cascading abort)
Because T1 dropped X(C) before committing, T2 could read a value that never became permanent. When T1 rolls back, every transaction that read T1's uncommitted writes must also roll back — a cascade. Strict 2PL fixes this by holding all X locks until commit/abort: no one can read C until T1's fate is decided, so dirty reads and cascading aborts are impossible. This is exactly why production systems use strict (and usually strong-strict / SS2PL) rather than basic 2PL.
Postgres: MVCC for reads, strict-2PL for writes
Postgres looks like a contradiction — its readers don't block writers, yet its docs describe strict-2PL-style locking. The resolution is that it is a hybrid. Read visibility is governed by MVCC: a plain SELECT takes no lock at all, reading a consistent snapshot version. That is precisely why readers never block writers and vice versa — a pure-2PL system would take an S read lock that blocks an X write lock, the reader-writer blocking Postgres avoids. Writes, on the other hand, follow strict 2PL: an UPDATE or DELETE takes a row-level X lock held until the end of the transaction. SELECT … FOR UPDATE opts a read into that same write-lock discipline, and table-level / DDL locks (eight modes, from ACCESS SHARE for a plain SELECT to ACCESS EXCLUSIVE for DROP/TRUNCATE) follow the same S/X principle: two transactions cannot hold conflicting-mode locks on the same table at once.
See Also
Isolation Levels & Read Anomalies The optimistic counterpart: MVCC/SSI runs freely and aborts conflicts at commit where 2PL blocks them upfront. Serializable is the level 2PL implements. ACID Properties 2PL is the machinery behind the "I" — isolation as a tunable dial — and strict 2PL underpins recoverability and atomicity on rollback. Deadlocks — Detection, Prevention & the Coffman Conditions 2PL's blocking creates deadlocks (e.g. the S→X upgrade trap); conservative 2PL is the deadlock-free variant by killing hold-and-wait. Covering Indexes & Index-Only Scans The same MVCC machinery that lets Postgres reads skip locks also governs whether an index-only scan can skip the heap.Sources consulted
- Wikipedia — Two-phase locking — fetched 2026-06-12
- PostgreSQL Documentation — Explicit Locking — fetched 2026-06-12
Test Yourself
What is the single defining rule of two-phase locking?
Two transactions each hold an S lock on the same row and both try to upgrade to X. What happens, and why?
What does Strict 2PL add to basic 2PL, what problem does it solve, and what happens to the shrinking phase for write locks?
Why is Conservative (Static) 2PL deadlock-free, and why is it impractical?
Postgres readers don't block writers, yet it uses "strict 2PL." What does it use MVCC for versus strict-2PL-style locking?