⏱️ Lectura: 14 min
When you transfer money between two different banks, two separate databases have to confirm the operation at the same time, or neither should move a single cent. The protocol that has solved this problem since 1978 is called two-phase commit, and it still runs inside PostgreSQL, MySQL, and architectures like Google Spanner every time a transaction spans more than one node.
📑 En este artículo
- TL;DR
- What two-phase commit is and why it matters
- How it works: the preparation phase and the commit phase
- Practical examples: implementing two-phase commit
- Getting started: enabling prepared transactions step by step
- Real-world use cases
- Common mistakes and best practices
- Comparison with alternatives
- Going deeper: why modern systems avoid pure 2PC
- Frequently Asked Questions
- What’s the difference between two-phase commit and a normal commit?
- What happens if the coordinator crashes after the preparation phase?
- Does two-phase commit guarantee availability during a network partition?
- Can I use two-phase commit across databases from different vendors?
- How do I remove a prepared transaction that got orphaned?
- Is two-phase commit still relevant in 2026?
- References
This guide explains how two-phase commit works step by step, with runnable examples in PostgreSQL, its known failure modes, and when it makes sense to replace it with more modern alternatives like Sagas or Raft-style distributed consensus.
TL;DR
- You’ll understand the two phases (prepare and commit) that two-phase commit uses to coordinate multiple nodes.
- You’ll run a real distributed transaction with PREPARE TRANSACTION in PostgreSQL.
- You’ll be able to check pending prepared transactions with the pg_prepared_xacts view.
- You’ll identify the blocking problem that arises if the coordinator crashes between phases.
- You’ll compare two-phase commit against 3PC, Sagas, and Raft-style consensus to choose wisely.
- You’ll learn MySQL’s XA syntax as an alternative to PostgreSQL’s.
- You’ll know how to clean up orphaned prepared transactions before they block production.
What two-phase commit is and why it matters
Two-phase commit (2PC) is a coordination protocol that guarantees a transaction touching multiple nodes ends the same way on all of them: either it commits on every node, or it aborts on every node. There’s no middle ground where one node commits and another doesn’t.
Jim Gray designed it in 1978 as part of his work on database operating systems, and it has since become the reference mechanism for giving atomicity to distributed transactions. The core idea is simple: split the decision to apply the change into two steps, one where each node promises it can follow through, and another where the coordinator executes that promise (or cancels it) on all of them at once.
In terms of ACID properties, two-phase commit extends atomicity beyond a single database engine. A normal transaction already guarantees atomicity within one node: if the process crashes halfway through, the engine rolls everything back on restart. Two-phase commit brings that same guarantee to a group of nodes that, individually, know nothing about each other: each one only understands prepare, commit, and abort, and trusts the coordinator to decide for all of them at once.
It matters today because any system with sharding, with microservices sharing a transaction, or with heterogeneous databases connected through XA middleware, needs something like two-phase commit to avoid ending up with inconsistent data across nodes.
How it works: the preparation phase and the commit phase
Two-phase commit splits the transaction into two rounds of communication between a coordinator and N participants, the nodes that are going to write data.
Phase 1: preparation (voting phase)
The coordinator sends a prepare message to each participant. Each participant executes the operation locally but doesn’t commit it yet: it locks the rows or tables involved and writes the prepared state to its own transaction log, durably on disk. It then responds YES (I can commit) or NO (I can’t, for example if it violates a constraint or there’s a locking conflict).
Phase 2: confirmation (commit phase)
If all participants voted YES, the coordinator sends commit to everyone and each one applies the change permanently and releases its locks. If at least one voted NO, or didn’t respond within the expected time, the coordinator sends abort to everyone, and each participant rolls back what it had prepared.
Why the prepared state must be written to disk
Two-phase commit’s guarantee depends on that, once a participant answers YES, that promise survives a restart. That’s why each participant writes its vote and the pending changes to its own write-ahead log before responding to the coordinator. If the process crashes after voting YES and restarts, on startup it reads that log again, finds the transaction in the prepared state, and waits for the coordinator’s final order instead of losing the work.
sequenceDiagram
participant Co as Coordinator
participant A as Node A
participant B as Node B
Co->>A: prepare tx_482
Co->>B: prepare tx_482
A-->>Co: vote YES
B-->>Co: vote YES
Co->>A: commit tx_482
Co->>B: commit tx_482
Note over Co,B: the transaction is confirmed on both nodes
Practical examples: implementing two-phase commit
Example 1: a minimal coordinator in Python
Before touching real SQL, it helps to see the bare logic. This toy coordinator simulates the voting and the final decision with no persistence or network involved:
class Participante:
def __init__(self, nombre):
self.nombre = nombre
def prepare(self):
return "YES"
def commit(self):
print(f"{self.nombre}: transaction committed")
def abort(self):
print(f"{self.nombre}: transaction aborted")
def coordinar(participantes):
votos = [p.prepare() for p in participantes]
if all(voto == "YES" for voto in votos):
for p in participantes:
p.commit()
return "COMMIT"
for p in participantes:
p.abort()
return "ABORT"
nodos = [Participante("cuenta_origen"), Participante("cuenta_destino")]
resultado = coordinar(nodos)
print(f"Global result: {resultado}")
The coordinar function asks each participant for its vote and only commits if all of them responded YES. Running it, the expected output is cuenta_origen: transaction committed, cuenta_destino: transaction committed, and Global result: COMMIT. If you change one node’s prepare to return NO, the global result becomes ABORT and no node commits anything.
Example 2: real two-phase commit with PostgreSQL
PostgreSQL implements two-phase commit natively with PREPARE TRANSACTION. Let’s simulate a transfer between two nodes (two separate databases, source_bank and destination_bank) that an external coordinator has to commit atomically:
-- Connection to node A (origin_bank)
BEGIN;
UPDATE cuentas SET saldo = saldo - 500 WHERE id = 'cuenta_juan';
PREPARE TRANSACTION 'transferencia_482';
-- Connection to node B (destination_bank)
BEGIN;
UPDATE cuentas SET saldo = saldo + 500 WHERE id = 'cuenta_maria';
PREPARE TRANSACTION 'transferencia_482';
-- The coordinator checks that both nodes prepared the same id
SELECT gid, prepared, owner FROM pg_prepared_xacts;
-- If both appear listed, commit on each node separately
COMMIT PREPARED 'transferencia_482'; -- run on node A
COMMIT PREPARED 'transferencia_482'; -- run on node B
Each PREPARE TRANSACTION leaves the change locked and durable, but invisible to other transactions until the commit. The pg_prepared_xacts view is how you confirm, from the outside, that both nodes actually prepared the same transaction before deciding on the final commit. If one of them doesn’t show up in that view, the coordinator must issue ROLLBACK PREPARED on the one that did prepare, so the system isn’t left half-done.
Getting started: enabling prepared transactions step by step
By default, PostgreSQL ships with this feature turned off. To try it in a local or test environment:
- Open
postgresql.confand setmax_prepared_transactions = 10(the default value is 0, which disablesPREPARE TRANSACTIONentirely). - Restart the server completely. This parameter doesn’t support a simple reload, it needs a full process restart.
- Confirm the active value with
SHOW max_prepared_transactions;. - Run
BEGIN;, your operation, andPREPARE TRANSACTION 'unique_id';on each connection that’s participating. - Check the global state with
SELECT * FROM pg_prepared_xacts;before deciding. - Close with
COMMIT PREPARED 'unique_id';on each node, orROLLBACK PREPARED 'unique_id';if one of them failed.
flowchart TD
A["BEGIN"] --> B["PREPARE TRANSACTION"]
B --> C{"Did all nodes vote YES?"}
C -->|"yes"| D["COMMIT PREPARED"]
C -->|"no"| E["ROLLBACK PREPARED"]
D --> F["Transaction committed on all nodes"]
E --> G["Transaction aborted on all nodes"]
💡 Tip: use a unique, deterministic identifier (gid) per business transaction, for example the order id, so you can locate it in pg_prepared_xacts if the coordinator crashes and you need to recover it by hand.
Real-world use cases
Two-phase commit shows up in several concrete contexts, not just in academic papers:
- XA middleware: the X/Open XA standard, used by JTA in Java EE, coordinates transactions across different databases and message queues using the same prepare-and-commit scheme.
- MySQL also supports it: with the XA START, XA END, XA PREPARE, and XA COMMIT syntax, equivalent in spirit to PostgreSQL’s.
- Sharded databases: when a business transaction touches two different shards, for example moving inventory between two partitions, 2PC is the classic way to keep them in sync.
- Cross-engine migrations: moving data from one database to another without an inconsistency window uses variants of this protocol.
- Message queues alongside a database: systems that need to write to a table and publish a message atomically evaluated 2PC between the broker and the database, though today most teams prefer the outbox pattern to avoid that coupling.
- Google Spanner: combines two-phase commit across groups that replicate with Paxos, to give atomicity to transactions that span several shards within the same system.
When two-phase commit is NOT a good idea: if your transaction crosses services you don’t fully control, for example a third-party provider’s API, you can’t trust that service to correctly implement the preparation phase, and you end up with a system that behaves like 2PC but without its real guarantees. In those cases, an event-based pattern with idempotent retries is usually safer than forcing a synchronous consensus protocol.
Common mistakes and best practices
The most commonly cited flaw in two-phase commit is the blocking problem: if the coordinator crashes after all participants voted YES, but before sending the commit order, each participant is left holding its locks with no way to know whether it should commit or abort. It can’t decide on its own, because another node might have voted NO and the coordinator simply never got word to it in time.
flowchart TD
A["Coordinator sends PREPARE"] --> B["Node A votes YES and locks rows"]
A --> C["Node B votes YES and locks rows"]
B --> D["Coordinator crashes before sending COMMIT"]
C --> D
D --> E["Node A and Node B stay blocked waiting for the final order"]
subgraph "Risk zone"
D
E
end
⚠️ Watch out: in PostgreSQL, an orphaned prepared transaction doesn’t just lock rows: it also stops autovacuum from cleaning up dead tuples in those tables, because the system has to preserve them in case the transaction still gets committed. A forgotten transaction can bloat a production table for days.
Best practices to avoid this in production:
- Monitor
pg_prepared_xactsand alert if a row has been unresolved for more than a few minutes. - If you use JTA/XA middleware, review its heuristic decision settings: by default, some allow a participant to decide on its own after a timeout, which can break atomicity if the coordinator was alive but slow.
- Use short timeouts in the preparation phase: the longer a node holds its locks, the more impact it has on the rest of the traffic.
- Store the coordinator’s log on durable storage separate from the participants, so you can recover and finish hung transactions after a restart.
Comparison with alternatives
| Option | When to use it | Advantage | Limitation |
|---|---|---|---|
| Two-Phase Commit (2PC) | Short transactions across a few nodes that need strict atomicity | Strong consistency: all or nothing | Locks resources and depends on the coordinator being available |
| Three-Phase Commit (3PC) | When you need to reduce indefinite blocking if the coordinator crashes | Adds an intermediate phase that keeps everyone from staying stuck forever | One extra round trip of latency and still doesn’t handle network partitions well |
| Saga (compensating transactions) | Long business flows across microservices | Doesn’t hold locks open between steps, tolerates partial failures | You have to write a compensating operation for each step |
| Raft or Paxos-style consensus | Replicating the same data across several nodes in one cluster | Keeps working as long as a majority of nodes are alive | Solves replication within a cluster, not coordination across different clusters |
Going deeper: why modern systems avoid pure 2PC
Two-phase commit requires, at minimum, two full round trips before it can commit anything. On a local database that’s cheap; across different geographic regions, each round can cost several milliseconds, and the entire transaction ends up waiting on the slowest response among all participants.
Google Spanner solves part of the problem by separating two layers: within each replica group it uses Paxos consensus to tolerate individual node failures, and across different groups it uses two-phase commit only to coordinate the final transaction, minimizing how many actors depend on the central coordinator in each operation.
CockroachDB goes a step further with what it calls parallel commits: instead of waiting for a second full round to commit, the coordinator sends the writes and the commit intent in parallel, and only marks the transaction as committed without that second full network trip if everything went well. The general idea, reducing how many network round trips separate prepared from committed, is the direction almost all modern distributed databases are moving in.
Sagas solve the problem from a different angle: instead of holding locks open while waiting for a global decision, each step commits immediately and, if something fails later on, a compensating operation runs, for example canceling a reservation instead of keeping inventory locked. In exchange, the application loses the guarantee of strict isolation between steps: other processes can see intermediate states.
💭 Key takeaway: two-phase commit optimizes consistency over availability; Sagas do exactly the opposite. Choosing between the two is, fundamentally, choosing which side of the CAP theorem you want to land on for that particular flow.
To measure the real cost of these two rounds in your own environment, turn on \timing in psql and compare how long a PREPARE TRANSACTION followed by COMMIT PREPARED takes against a direct COMMIT of a single-node transaction: the difference gives you the concrete cost of the extra round trip on your own network.
📖 Summary on Telegram: View summary
Your next step: spin up two local PostgreSQL databases with Docker, enable max_prepared_transactions on both, and reproduce the transfer from Example 2 by hand to watch pg_prepared_xacts in real time.
Frequently Asked Questions
What’s the difference between two-phase commit and a normal commit?
A normal commit confirms a transaction within a single node. Two-phase commit coordinates several independent nodes to commit the same business transaction at once, using a voting phase before applying the change on any of them.
What happens if the coordinator crashes after the preparation phase?
The participants that already voted YES are left blocked, holding their resources, until the coordinator recovers and tells them whether to commit or abort. This is the blocking problem described above, and it’s the main criticism the protocol receives.
Does two-phase commit guarantee availability during a network partition?
No. 2PC prioritizes consistency over availability: if the coordinator can’t communicate with a participant, the entire transaction stays pending instead of moving forward partially.
Can I use two-phase commit across databases from different vendors?
Yes, that’s what the XA standard exists for: PostgreSQL, MySQL, Oracle, and SQL Server all implement the XA interface, and a JTA transaction manager can coordinate 2PC across all of them.
How do I remove a prepared transaction that got orphaned?
Identify it with SELECT * FROM pg_prepared_xacts; and run ROLLBACK PREPARED 'gid'; (or COMMIT PREPARED 'gid'; if you know the rest of the nodes did commit) using the same user that created it.
Is two-phase commit still relevant in 2026?
Yes, as an internal building block inside distributed databases like Spanner and CockroachDB, and in enterprise XA integrations, though for business flows across microservices most teams today prefer Sagas for their tolerance of partial failures.
References
- Wikipedia: Two-phase commit protocol: history and formal description of the algorithm.
- PostgreSQL Docs: PREPARE TRANSACTION: official syntax and the max_prepared_transactions parameter.
- PostgreSQL Docs: pg_prepared_xacts: view for inspecting pending prepared transactions.
- MySQL Docs: XA Transactions: XA START, XA PREPARE, and XA COMMIT syntax in MySQL.
📱 Enjoying this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day.
Imagen destacada: Foto de Kier in Sight Archives en Unsplash
0 Comments