What you'll learn
- Quick answer
- The Problem: One Transaction, Several Databases
- Phase 1: Prepare — Can You Definitely Do This?
- Phase 2: The Decision — Commit or Abort, No Middle Ground
- The Blocking Problem: 2PC's Best-Known Flaw
- What Happens When a Participant Crashes Instead
- Where 2PC Actually Shows Up
- Why Most Systems Avoid Plain 2PC at Scale
- FAQ
Quick Answer
Two-Phase Commit (2PC) keeps a transaction spanning multiple databases atomic: a coordinator first asks every participant to prepare and vote, and only tells everyone to commit if every vote is yes — if even one participant votes no, everyone rolls back. Its well-known weakness is that if the coordinator crashes after the votes are in but before the decision goes out, participants are stuck holding locks until it recovers.
The Problem: One Transaction, Several Databases
A single-database transaction gets atomicity for free — the database's own write-ahead log guarantees that either all of a transaction's changes become durable, or none of them do, even across a crash. That guarantee stops at the database's own boundary. The moment a single logical operation needs to touch two separate databases — debit an account in one bank's system and credit an account in another, or charge a customer in one service and decrement inventory in a completely separate one — there's no single write-ahead log covering both anymore. Each database can independently succeed or fail, and without coordination you can end up in a state where the money left one system and never arrived at the other.
The Two-Phase Commit protocol (2PC) is the classic answer to that gap. It makes a group of independent databases, called participants, behave — for the duration of one transaction — as if they were a single database, coordinated by one node (the coordinator) whose job is to make sure everyone agrees on the same outcome.
Phase 1: Prepare — Can You Definitely Do This?
The coordinator starts by sending a PREPARE message to every participant. Each participant does the transaction's work tentatively — writes the change to its own durable log and acquires whatever locks it needs to hold the affected rows steady — but does not commit yet. It then votes YES (“I've durably staged this, and I promise I can commit it no matter what happens next, including a crash and restart”) or NO (“I can't do this — a constraint failed, I'm out of resources, whatever the reason”).
The word promise is doing real work in that sentence. Once a participant votes YES, it gives up the right to unilaterally change its mind. If it crashes and restarts five minutes later, it has to recover its prepared state from its own durable log and still honor that vote whenever the coordinator eventually asks it to commit. That's why the prepare step has to hit durable storage before the vote goes out — a YES vote that isn't actually safe on disk yet would break the entire protocol's guarantee.
Phase 2: The Decision — Commit or Abort, No Middle Ground
Once the coordinator has every vote in hand, it makes exactly one decision for the whole transaction: if every participant voted YES, it durably records “commit” in its own log, then sends COMMIT to everyone. If even a single participant voted NO — or simply didn't answer in time — it decides to abort and sends ROLLBACK to everyone instead, including the ones that voted YES. Nobody gets to keep only their own part of the transaction; it's all or nothing, cluster-wide.
Here's a minimal but complete simulation — a coordinator and three participants, each able to vote yes or no in phase 1:
class Participant:
def __init__(self, name, will_vote_yes=True):
self.name = name
self.will_vote_yes = will_vote_yes
self.state = "INIT"
def prepare(self, txn_id):
if self.will_vote_yes:
self.state = "PREPARED"
return "YES"
self.state = "ABORTED"
return "NO"
def commit(self, txn_id):
self.state = "COMMITTED"
def rollback(self, txn_id):
self.state = "ABORTED"
class Coordinator:
def __init__(self, participants):
self.participants = participants
def run_transaction(self, txn_id):
votes = {p.name: p.prepare(txn_id) for p in self.participants}
all_yes = all(v == "YES" for v in votes.values())
if all_yes:
for p in self.participants: p.commit(txn_id)
return "COMMITTED"
for p in self.participants: p.rollback(txn_id)
return "ABORTED"Running it with three participants that all vote YES produces a clean commit:
--- Transaction txn-1001: PHASE 1 (PREPARE / VOTE) ---
Coordinator -> Bank-A: PREPARE Bank-A -> Coordinator: YES
Coordinator -> Bank-B: PREPARE Bank-B -> Coordinator: YES
Coordinator -> Inventory-Service: PREPARE Inventory-Service -> Coordinator: YES
--- Transaction txn-1001: PHASE 2 (COMMIT) ---
Coordinator -> Bank-A: COMMIT
Coordinator -> Bank-B: COMMIT
Coordinator -> Inventory-Service: COMMIT
--- Transaction txn-1001 outcome: COMMITTED ---Running the exact same code again, but this time with the inventory service unable to prepare (say, the item just went out of stock), flips the entire transaction to an abort — including rolling back the two banks that had already voted YES:
--- Transaction txn-1002: PHASE 1 (PREPARE / VOTE) ---
Coordinator -> Bank-A: PREPARE Bank-A -> Coordinator: YES
Coordinator -> Bank-B: PREPARE Bank-B -> Coordinator: YES
Coordinator -> Inventory-Service: PREPARE Inventory-Service -> Coordinator: NO
--- Transaction txn-1002: PHASE 2 (ABORT) ---
Coordinator -> Bank-A: ROLLBACK
Coordinator -> Bank-B: ROLLBACK
Coordinator -> Inventory-Service: ROLLBACK
--- Transaction txn-1002 outcome: ABORTED ---That's the entire protocol in miniature: a single NO anywhere in phase 1 is enough to unwind everyone, even participants that had already done their part correctly and were fully prepared to commit.
The Blocking Problem: 2PC's Best-Known Flaw
The protocol above has one well-documented, structural weakness — not a rare edge case, but a property of the protocol itself: what happens if the coordinator crashes after collecting all the votes but before sending out the decision?
Every participant that voted YES is now stuck. It can't unilaterally commit, because it has no way of knowing whether some other participant voted NO. It can't unilaterally abort either, because the coordinator might already have decided to commit and simply not gotten the message out before crashing. All it can do is sit in the prepared state, holding its locks, waiting for the coordinator to come back. Any other transaction that needs those same locked rows blocks too. During the coordinator's downtime — seconds to minutes, depending on how recovery is handled — all of those locked rows are unavailable to everything else in the system, which is exactly why 2PC is described as a blocking protocol: a coordinator failure at the wrong instant can freeze part of the system rather than the system routing around it the way it would around an ordinary node failure.
What Happens When a Participant Crashes Instead
A participant can also fail, and the recovery story depends on exactly when.
If a participant crashes before voting, the coordinator simply times out waiting for its vote and treats the silence as a NO — the transaction aborts, the same as if it had explicitly voted no. Nothing was promised yet, so there's nothing to recover.
If a participant crashes after voting YES but before hearing the final decision, it's in a genuinely trickier spot on restart. It can't just discard the transaction — it already promised to honor whatever the coordinator decided. Instead, it has to replay its own durable prepared-state log and then actively ask around (the coordinator, or in some protocol variants, other participants) to learn what was actually decided while it was down, before it can safely commit or roll back. This process is sometimes called the termination protocol, and it's a direct consequence of the promise a YES vote makes.
Where 2PC Actually Shows Up
Despite its reputation, 2PC isn't a purely academic protocol — it's the mechanism underneath several things you may already rely on.
- XA transactions, the standard behind Java's JTA and coordinators like Microsoft's MSDTC, are essentially 2PC formalized as an API that any XA-compliant resource — a database, a message queue — can plug into.
- PostgreSQL exposes raw 2PC primitives directly:
PREPARE TRANSACTIONandCOMMIT PREPARED/ROLLBACK PREPARED, meant to be driven by an external transaction manager coordinating Postgres alongside other resources. - Google Spanner uses two-phase commit for transactions that span multiple shards (Paxos groups) — but it specifically engineers around 2PC's classic weak point by making the coordinator role itself a Paxos-replicated group rather than a single machine. If the coordinator's current leader dies, the group elects a new one that already has the transaction's state, instead of the whole transaction blocking until one specific machine happens to come back online.
Why Most Systems Avoid Plain 2PC at Scale
Outside of cases like Spanner that specifically engineer around the blocking problem, most large-scale distributed systems reach for something other than plain 2PC when coordinating across services. Three reasons show up repeatedly: the blocking problem above ties the whole transaction's availability to whichever participant or coordinator is doing worst at any given moment; locks are held across the entire round trip of both phases over a network, which is far slower than holding them for a single-node transaction; and every participant has to be reachable and responsive, so the transaction is only as available as its least available participant.
For loosely coupled microservices, the more common answer is the Saga pattern, covered elsewhere on this site: break the transaction into a sequence of independent local transactions, each with its own compensating action to undo it if a later step fails, and accept eventual consistency instead of the strict atomicity 2PC provides. 2PC still wins when you genuinely need that atomicity and control every participant — as inside Spanner, or within one company's own set of databases. Sagas win when you're coordinating across services you don't fully control and can tolerate a brief window of inconsistency along the way.
There's also a middle ground worth knowing about: three-phase commit (3PC) adds an extra “pre-commit” acknowledgment step specifically so a participant can safely time out and commit — or abort — on its own instead of blocking forever. The catch is that 3PC only actually delivers that guarantee under the assumption that network delay is bounded; in a real, asynchronous network where messages can be arbitrarily delayed, 3PC can end up with some nodes committing and others aborting, which is a large part of why it never displaced plain 2PC in practice.
