← databases book ⊞ All topics

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:

VariantRelease X (write) locksRelease S (read) locksProperty
Basic 2PLanytime in shrinking phase (even pre-commit)anytime in shrinking phaseserializable; allows cascading aborts
Strict 2PL (S2PL)at commit / abortshrinking phase (early)recoverable; no cascading aborts
Strong Strict / Rigorous (SS2PL)at commit / abortat commit / abortsimplest; what real systems use
Conservative (Static)acquire all locks before startacquire all upfrontdeadlock-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.

Step 1 of N
Locks held over time — the two-phase mountain locks time / statements → ① read A ② read B ③ write C ④ commit GROWING — acquire, never release S(A) S(B) LOCK POINT peak: all held SHRINKING — release, never acquire Basic: X(C) freed pre-commit Strict: S early, X(C) held to commit Order txns by lock point → serial order

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.

Anti-pattern: using Basic 2PL — releasing write locks during the shrinking phase, before commit. It is serializable, but it leaks uncommitted data and chains failures.
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.

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?