Two-Phase Locking (2PL) β Part 1: Foundations & Core Concepts
maang.io System Design Series
What is this?
Picture a busy shared reference library β the old-fashioned kind where the rare books never leave the building. You're one of several researchers, and each of you is compiling a report by reading and correcting facts across several books. Here's the rule the librarian enforces to stop your reports from contradicting each other: before you can touch a book, you take out a card for it. There are two kinds of card. A reading card lets you read a book, and lots of people can hold reading cards for the same book at once β reading doesn't hurt anyone. But an editing card lets you scribble corrections in a book, and while you hold it, nobody else may hold any card for that book at all. You collect the cards you need, do your work, and hand the cards back.
There's one more rule, and it's the whole trick: once you've handed back your first card, you're not allowed to take out any new ones. You collect, collect, collectβ¦ and then you only ever give back. You can't return a book, notice you need another, and grab it β because in the moment you let go of that first book, someone else might have rewritten it under you, and now your report is built on a fact that no longer exists.
That single discipline β grab everything you'll need before you start letting go β is Two-Phase Locking (2PL). It's the classic protocol databases use to run many transactions at the same time while producing a result that's as correct as if they'd run one after another. Those two kinds of card are shared and exclusive locks; the collect-then-release shape is the two phases the name is talking about.
The one-line idea: A transaction acquires all its locks in a growing phase and releases them in a shrinking phase, and the instant it releases its first lock it may never acquire another. That one rule is mathematically enough to guarantee the interleaved schedule is serializable β equivalent to some serial, one-at-a-time order.
Let's see why anyone needs this, then build up the vocabulary.
1. The problem: concurrency without a referee is a minefield
In the ACID chapter you met isolation β the "I" β the promise that concurrent transactions don't step on each other. The easy way to keep that promise is to run transactions strictly one at a time (a serial schedule). That's obviously correct, and obviously far too slow: one transaction stalling on disk would freeze every other user on the system.
So databases interleave transactions β run bits of many at once. The trouble is that naΓ―ve interleaving produces anomalies, wrong answers that could never happen in any serial order. The classic one is the lost update. Two people transfer money into the same account, which starts at $100:
Both read 100, both wrote their own answer, and the final balance is 130 instead of 180. Fifty dollars vanished. Other members of this family: the dirty read (you read a value another transaction wrote but hasn't committed, and it then rolls back), the non-repeatable read (you read a row twice in one transaction and get two different values), and the phantom (you run the same WHERE query twice and a new row appears). These aren't exotic β they're what you get by default the moment two transactions touch the same data with no referee.
2PL is that referee. It lets transactions interleave for speed, but constrains how they take and give back locks so tightly that the only schedules it permits are the ones equivalent to running the transactions in some serial order. You get concurrency's throughput with a serial schedule's correctness.
2. The two kinds of lock, and when they collide
A lock is just a claim a transaction registers on a data item (a row, usually) before touching it. 2PL uses two modes β our two library cards:
- Shared lock (S) β "I'm reading this." Taken before a read. Many transactions can hold an S lock on the same item at once, because concurrent reads never conflict.
- Exclusive lock (X) β "I'm writing this." Taken before a write. Only one transaction may hold it, and no S locks may coexist with it.
Whether a lock request is granted depends entirely on what's already held, summarized by the compatibility matrix β the single most important table in this whole topic:
| Requested β / Held β | S (shared) | X (exclusive) |
|---|---|---|
| S (shared) | β compatible | β wait |
| X (exclusive) | β wait | β wait |
Read it as: reads don't block reads, but a write blocks everyone and is blocked by everyone. If a transaction requests a lock that isn't compatible with what's held, it doesn't fail β it waits in a queue until the conflicting lock is released. This is why 2PL is called a pessimistic concurrency-control scheme: it assumes conflict and blocks up front to prevent it, rather than letting everyone run and checking for damage at the end (that's the optimistic approach, and MVCC β Part 2 β is a different bet again).
π‘ A lock upgrade is when a transaction that holds an S lock on a row (it read it) later wants to write it, and asks to convert its S into an X. That's allowed only if no other transaction holds an S on that row. Upgrades are convenient but, as you'll see in Part 2, a notorious source of deadlocks β two transactions each holding S and each waiting to upgrade to X, forever.
3. The two phases β and the rule that makes it work
Now the heart of it. Every transaction under 2PL passes through exactly two phases, in order:
- Growing phase: the transaction acquires locks as it needs them. It may acquire many; it may not release any yet.
- Lock point: the instant it acquires its last lock β the peak, where it holds the most locks it ever will.
- Shrinking phase: the transaction releases locks. Once it has released even one, the growing phase is over forever β it may never acquire another lock.
That's the entire protocol. And here's the remarkable part: this rule alone is enough to prove that any schedule 2PL allows is conflict-serializable β equivalent to some serial order of the transactions. The intuition, back in the library: because you hold all your cards simultaneously at the lock point, there's a single instant where you own every book your report depends on. No other researcher can have snuck a conflicting edit in between your reads and your writes, because they couldn't have held any card for those books during your reign. Your whole transaction effectively "happens" at that lock point, and the lock points give a consistent serial order for everyone.
β οΈ Basic 2PL guarantees serializability but not that the result is safe to recover from. If a transaction releases a lock during its shrinking phase, another transaction can read that value β and if the first one then aborts, the second has read data that never really existed (a dirty read), and must abort too. That domino effect is a cascading abort, and the fix is the variants below.
4. Strict and rigorous 2PL β the versions databases actually use
Real databases almost never use basic 2PL, precisely because of cascading aborts. They use a stricter cousin that changes when locks are released:
- Strict 2PL (S2PL): hold all exclusive (write) locks until the transaction commits or aborts. Shared locks may be released earlier. Because no one can see your writes until you commit, dirty reads and cascading aborts become impossible. This is the workhorse of commercial databases.
- Rigorous 2PL: hold all locks β shared and exclusive β until commit/abort. Slightly less concurrency than strict, but the simplest to reason about, and it has a lovely property: the serialization order is exactly the commit order. Many systems use this.
Both collapse the shrinking phase into a single instant β commit time β so in practice the "two phases" become "acquire as you go, then drop everything at commit." Back in the library: you keep every card until your report is finished and filed, so nobody ever reads a half-corrected book. This is the version worth picturing for the rest of the topic.
5. How you actually use it
Here's the good news: you almost never write 2PL. Your database's lock manager runs it for you underneath every transaction. What you control is the isolation level, which is really a dial on how strictly locks are held. Ask for SERIALIZABLE and (in a lock-based engine) you get full 2PL plus the range locks of Part 2; ask for a weaker level and the engine releases read locks earlier, trading correctness for concurrency.
When you do reach for locks directly, it's usually to force an exclusive lock on rows you're about to read-then-write β closing the lost-update hole from Section 1:
BEGIN;
-- Take an EXCLUSIVE lock on this row NOW, at read time.
-- Any other transaction that tries to lock it waits until we commit.
SELECT balance FROM accounts
WHERE id = 42
FOR UPDATE; -- β the explicit X-lock request
UPDATE accounts SET balance = balance + 50 WHERE id = 42;
COMMIT; -- strict 2PL releases the lock here
SELECT ... FOR UPDATE is you saying "I'll be writing this, lock it exclusively up front" β no other transaction can read-for-update or write that row until you commit, so the interleaving that lost $50 simply can't happen. Its gentler sibling FOR SHARE (MySQL: LOCK IN SHARE MODE) takes a shared lock β "I'm reading this and I need it to stay put until I commit, but others may read it too." These are your manual handles on the growing phase.
6. When to reach for it β and when not
2PL (pessimistic locking) is the right instinct when:
- Contention is genuinely high and conflicts are likely β inventory decrements on a hot product, seat/ticket booking, a shared counter. Blocking up front is cheaper than doing work and throwing it away.
- You need true serializability, not just snapshot isolation β correctness that must survive the nastiest interleavings.
- Conflicts are expensive to retry β long transactions you'd hate to redo from scratch.
Reach for something else when:
| Situation | Better fit |
|---|---|
| Read-heavy, writes rarely conflict | MVCC β readers never block writers; each read sees a consistent snapshot (Part 2 contrasts the two). |
| Conflicts are rare and retries cheap | Optimistic concurrency control β run freely, check a version number at commit, retry on clash. |
| A transaction spans many machines | You still need 2PL per node, but atomicity across nodes is a separate problem β see the Distributed Transactions topic and two-phase commit (a different "two-phase"!). |
| You just need one atomic increment | A single UPDATE ... SET x = x + 1 or an atomic counter β no explicit locking dance needed. |
π‘ The mental filing you want: 2PL is pessimistic and blocks; MVCC is version-based and rarely blocks readers; OCC is optimistic and retries. Most real databases blend them β e.g. MySQL's InnoDB and SQL Server use MVCC for plain reads and strict 2PL-style locking for writes and
FOR UPDATE. They're not rivals so much as tools for different parts of the same engine.
Key takeaways
- 2PL is the classic referee for concurrency. It lets transactions interleave for throughput while guaranteeing the result is serializable β equivalent to some one-at-a-time order β which is exactly the isolation the ACID chapter demands.
- Two lock modes, one compatibility rule: shared (S) locks coexist; an exclusive (X) lock blocks and is blocked by everyone. Reads don't block reads; writes block everyone.
- The whole protocol is one rule: acquire locks in a growing phase, release them in a shrinking phase, and never acquire a lock after releasing one. The lock point is where you hold them all β that's what pins your transaction to a single serial position.
- Real databases use strict/rigorous 2PL β hold write locks (or all locks) until commit β to kill dirty reads and cascading aborts. You steer it through isolation levels and explicit
SELECT ... FOR UPDATE/FOR SHARE. - Pessimistic by design. Reach for 2PL under real contention or when you need true serializability; reach for MVCC or optimistic control when reads dominate or conflicts are rare.
Next, Part 2 opens the hood: the lock table data structure, how a lock request is granted or queued, the deadlocks 2PL invites and how engines detect and break them (wait-for graphs, victim selection, wait-die/wound-wait, lock ordering), predicate and range locks for phantoms, and a head-to-head with MVCC β all with worked numbers.