⏱️ 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
  1. TL;DR
  2. What two-phase commit is and why it matters
  3. How it works: the preparation phase and the commit phase
    1. Phase 1: preparation (voting phase)
    2. Phase 2: confirmation (commit phase)
    3. Why the prepared state must be written to disk
  4. Practical examples: implementing two-phase commit
    1. Example 1: a minimal coordinator in Python
    2. Example 2: real two-phase commit with PostgreSQL
  5. Getting started: enabling prepared transactions step by step
  6. Real-world use cases
  7. Common mistakes and best practices
  8. Comparison with alternatives
  9. Going deeper: why modern systems avoid pure 2PC
  10. Frequently Asked Questions
    1. What’s the difference between two-phase commit and a normal commit?
    2. What happens if the coordinator crashes after the preparation phase?
    3. Does two-phase commit guarantee availability during a network partition?
    4. Can I use two-phase commit across databases from different vendors?
    5. How do I remove a prepared transaction that got orphaned?
    6. Is two-phase commit still relevant in 2026?
  11. 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.

Conceptual diagram of two-phase commit between a coordinator and database nodes
The coordinator only commits if all nodes responded YES during the preparation phase. Foto de Matilda Alloway en Unsplash

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.

Database servers connected in a distributed architecture
Each node keeps its own log of prepared transactions until it receives the final order. Foto de Brett Jordan en Unsplash

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:

  1. Open postgresql.conf and set max_prepared_transactions = 10 (the default value is 0, which disables PREPARE TRANSACTION entirely).
  2. Restart the server completely. This parameter doesn’t support a simple reload, it needs a full process restart.
  3. Confirm the active value with SHOW max_prepared_transactions;.
  4. Run BEGIN;, your operation, and PREPARE TRANSACTION 'unique_id'; on each connection that’s participating.
  5. Check the global state with SELECT * FROM pg_prepared_xacts; before deciding.
  6. Close with COMMIT PREPARED 'unique_id'; on each node, or ROLLBACK 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_xacts and 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

OptionWhen to use itAdvantageLimitation
Two-Phase Commit (2PC)Short transactions across a few nodes that need strict atomicityStrong consistency: all or nothingLocks resources and depends on the coordinator being available
Three-Phase Commit (3PC)When you need to reduce indefinite blocking if the coordinator crashesAdds an intermediate phase that keeps everyone from staying stuck foreverOne extra round trip of latency and still doesn’t handle network partitions well
Saga (compensating transactions)Long business flows across microservicesDoesn’t hold locks open between steps, tolerates partial failuresYou have to write a compensating operation for each step
Raft or Paxos-style consensusReplicating the same data across several nodes in one clusterKeeps working as long as a majority of nodes are aliveSolves 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

📱 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


Andrés Morales

Developer and AI researcher. Writes about language models, frameworks, developer tooling, and open source releases. Covers ML papers, the tech startup ecosystem, and programming trends.

0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.