Sagas vs two-phase commit

What it is

Two ways to make a multi-service operation end in a consistent state, and they make opposite trades.

TWO-PHASE COMMIT (2PC)
  A coordinator asks every participant to PREPARE. Each
  votes yes or no and, if yes, durably promises it can
  commit. If all vote yes, the coordinator tells everyone to
  COMMIT.
  Guarantee: ATOMIC. Either all commit or none do.
  Cost: participants hold locks from prepare until commit,
  and the coordinator is a single point of failure that can
  block them indefinitely.

SAGA
  A sequence of local transactions, each committing
  immediately. If step N fails, run COMPENSATING
  transactions for steps 1 to N-1 to semantically undo them.
  Guarantee: EVENTUAL consistency, and atomicity is
  simulated rather than provided.
  Cost: intermediate states are VISIBLE, compensations are
  application logic, and some actions cannot be undone.

Commonly confused as equivalent options. They provide different guarantees: 2PC gives you atomicity and takes availability; a saga gives you availability and takes atomicity. Choosing "the one that scales" without saying which guarantee you are giving up is the weak answer.

Also commonly confused with the transactional outbox, which solves a narrower and much more common problem: writing to a database and publishing an event atomically. Most systems that think they need a saga need an outbox.

The problem it solves

An order spans four services:

  payment    charge the card
  inventory  decrement stock
  shipping   create a shipment
  notify     email the customer

There is no shared transaction. If shipping fails after
payment succeeded, the customer has been charged for
nothing.

The naive answer is "wrap it in a transaction", which does not exist across service boundaries. The two real options are to build a distributed transaction protocol (2PC) or to accept intermediate states and undo them (saga).

Mechanics

2PC, and why it is rarely used across services

PHASE 1: PREPARE
  coordinator -> each participant: "can you commit?"
  each participant:
    - does the work but does NOT commit
    - writes a durable prepare record
    - HOLDS LOCKS
    - votes yes or no

PHASE 2: COMMIT or ABORT
  if all yes -> coordinator writes its decision durably,
                then tells everyone to commit
  if any no  -> everyone aborts

The failure that defines it: the coordinator dies between phases.

Participants have voted yes. They hold locks. They have
promised they can commit and therefore CANNOT unilaterally
abort, because the coordinator may have already told someone
else to commit.

They are BLOCKED, holding locks, until the coordinator
recovers.

*** 2PC is a BLOCKING protocol. That is not an
    implementation weakness, it is a proved property: no
    protocol can be non-blocking with a single coordinator
    failure and asynchronous communication. ***

Three-phase commit adds a pre-commit phase to reduce blocking, and it assumes synchronous communication with bounded message delay, which real networks do not provide. In practice it is not used, and knowing why is a better signal than knowing it exists.

Where 2PC genuinely is used:

WITHIN one database across shards          (Spanner, and
                                            two-phase commit
                                            across Paxos
                                            groups)
Between a database and a message broker    (XA, historically)
Within a single organisation's tightly
  coupled systems with a reliable
  coordinator                              (a mainframe
                                            transaction
                                            monitor)

Where it is not: across service boundaries in a microservice architecture, because locks held across a network for the duration of a distributed protocol destroy throughput, and because the coordinator becomes an availability dependency for every participant.

Sagas: the two coordination styles

CHOREOGRAPHY                  ORCHESTRATION
Each service listens for      A central orchestrator
events and emits its own.     invokes each step and
No central coordinator.       decides what is next.

+ No single point of          + The flow is IN ONE PLACE
  failure                       and readable
+ Services are decoupled      + Easy to see where a saga
                                is and why it failed
+ Easy to add a participant   + Timeouts and retries are
                                centralised

- The flow exists NOWHERE.    - The orchestrator is a
  Understanding it means        dependency, though not a
  reading every service.        lock-holding one
- Cyclic dependencies creep   - Risks becoming a god
  in easily                     service with business logic
- Debugging "why did this     - One more thing to run
  saga stall" is genuinely
  hard

Orchestration is the right default beyond about three steps, and the reason is operational rather than aesthetic: when a saga stalls at 3am, "read the code of five services and reconstruct the event flow" is a much worse position than "look at the orchestrator's state for this saga id".

# Orchestration, with the shape that matters: every forward
# step has a named compensation, and the compensation list is
# built as you go.
async def place_order(order):
    done = []
    try:
        auth = await payment.authorise(order.total, key=order.id)
        done.append(lambda: payment.void(auth.id))

        res = await inventory.reserve(order.items, key=order.id)
        done.append(lambda: inventory.release(res.id))

        ship = await shipping.create(order, key=order.id)
        done.append(lambda: shipping.cancel(ship.id))

        await payment.capture(auth.id, key=order.id)
        # No compensation after capture: this is the point of
        # no return, and it is placed LAST deliberately.

        await notify.send(order.customer, "confirmed")
        # Notification is best-effort and NOT compensated.

    except Exception:
        for undo in reversed(done):
            await with_retry(undo)      # compensations MUST
                                        # eventually succeed
        raise

Three design decisions visible in that code, and each is worth stating:

Compensations run in reverse order, because later steps may depend on earlier ones.

The irreversible step is placed last. Authorise early, capture late: an authorisation can be voided and a capture cannot easily be. Ordering the saga so the point of no return is as late as possible is the single most valuable design move, and it is available far more often than people assume.

Every call carries an idempotency key derived from the order id, because compensations and forward steps will both be retried.

Compensation is not rollback

ROLLBACK       restores the previous state exactly. The
               database does it and nobody observes the
               intermediate state.

COMPENSATION   applies a NEW transaction that semantically
               undoes the previous one. The intermediate
               state WAS visible, and the compensation is
               itself an observable event.

  charge $100      ->  compensation: refund $100
                       The customer saw a charge and a refund
                       on their statement. That is a support
                       call, not a rollback.

  send an email    ->  compensation: send a correction email
                       You cannot un-send.

  ship a package   ->  compensation: a returns process
                       Days, and it costs money.

The consequences that must be designed for:

1. INTERMEDIATE STATES ARE VISIBLE, so every consumer must
   tolerate them. An order in "payment authorised, inventory
   reserved, shipping failed" state exists and something
   will read it.

2. COMPENSATIONS MUST BE IDEMPOTENT AND MUST EVENTUALLY
   SUCCEED. A failed compensation leaves the system
   inconsistent with no further recovery, so it retries
   forever and alerts.

3. SOME ACTIONS CANNOT BE COMPENSATED. Order the saga so
   those are last, and if two are irreversible and both must
   happen, a saga is the wrong pattern.

4. SEMANTIC LOCKS may be needed. Marking an order
   "pending" so other processes do not act on it, which is
   an application-level lock and it can deadlock like any
   other.

Point 3 is the boundary condition: if the operation genuinely requires two irreversible actions to both succeed or both not happen, neither a saga nor 2PC across services helps, and the answer is to redesign so only one is irreversible.

The isolation problem, which sagas do not solve

ACID's I is what a saga gives up, not just A.

  Saga A: reserve 5 units of SKU-1, then fails, compensates.
  Saga B: between those, reads stock and sees 5 fewer units,
          and tells a customer the item is out of stock.

  Saga B read an intermediate state that was later undone.
  That is a DIRTY READ across services.

The countermeasures, from Garcia-Molina and Salem's original saga paper and later work:

SEMANTIC LOCK       a status field marking the record as
                    in-flight, checked by other sagas
COMMUTATIVE UPDATES design operations so order does not
                    matter (increment/decrement rather than
                    set)
PESSIMISTIC VIEW    reorder steps so the risky read happens
                    after the risky write is confirmed
RE-READ VALUE       verify a value has not changed before
                    acting on it

Naming that sagas sacrifice isolation as well as atomicity is a strong signal, because most descriptions mention only atomicity and the isolation failures are the ones that surprise people in production.

The outbox, which is what most systems actually need

THE ACTUAL PROBLEM in most "we need a saga" conversations:

  db.save(order)          # succeeds
  kafka.publish(event)    # fails
  -> the order exists and nothing downstream knows

THE OUTBOX
  BEGIN;
    INSERT INTO orders ...;
    INSERT INTO outbox (topic, payload, key) VALUES (...);
  COMMIT;
  -- one local transaction, atomic

  A relay reads the outbox (polling or CDC) and publishes,
  marking rows sent. At-least-once, so consumers dedupe.

This solves the dual-write problem without any distributed transaction, and it is a fraction of the complexity of a saga. The question to ask before designing a saga: is this actually a multi-step business process with compensations, or is it one write plus one publish?

A worked example: choosing for an order flow

THE FLOW
  reserve inventory -> authorise payment -> create shipment
  -> capture payment -> notify

THE 2PC ANSWER
  Requires all four services to support a prepare phase and
  hold locks across the whole flow. Payment providers do not
  offer prepare. Inventory would hold a row lock for the
  duration, including the shipping call's latency.
  -> Not available, and would be a throughput disaster if it
     were.

THE SAGA DESIGN
  Orchestrated, because five steps across four services is
  past the point where choreography is debuggable.

  ORDERING, chosen deliberately:
    1. reserve inventory       compensable: release
    2. authorise payment       compensable: void
    3. create shipment         compensable: cancel
    4. CAPTURE PAYMENT         irreversible-ish: refund is
                               visible to the customer
    5. notify                  not compensated, best effort

  The capture is fourth rather than second SPECIFICALLY so
  that a shipping failure voids an authorisation (invisible
  to the customer) rather than refunding a capture (visible,
  and a support call).

  ISOLATION HANDLING
    Inventory reservation is a semantic lock: the units are
    marked reserved, not decremented, so a concurrent read
    sees them as unavailable but a compensation restores
    them cleanly.
    Order status is explicit and every consumer handles the
    intermediate states.

  FAILURE HANDLING
    Compensations retry with backoff, forever, and alert
    after N attempts. A saga stuck in compensation is an
    operational item, not a silent inconsistency.
    The orchestrator persists saga state, so a crash resumes
    rather than restarting.

WHAT WOULD CHANGE THE ANSWER
  If capture had to happen before shipment for business
  reasons, the irreversible step would be second and a
  shipping failure would produce a customer-visible refund.
  At that point I would push back on the business ordering,
  because the technical cost of the sequence is real and the
  requirement is often softer than it sounds.

The lesson: the saga's step ordering is a design decision with a customer-visible consequence, and it is the part that gets least attention.

Production evidence

Garcia-Molina and Salem, "Sagas" (SIGMOD 1987) is the original, and it introduced both the compensation model and the countermeasures for the isolation problem, which is worth knowing because the isolation half is usually dropped in modern retellings.

The X/Open XA specification is the standard 2PC interface, and its declining use across service boundaries (while remaining in single-database distributed transactions) is the practical evidence for the position above.

Spanner uses two-phase commit across Paxos groups within one database, which is the case where 2PC works well: the coordinator is itself replicated, so the blocking failure mode is addressed by consensus rather than tolerated.

Chris Richardson's microservices.io saga pattern documents both choreography and orchestration with the trade-offs, and the transactional outbox pattern on the same site is the narrower solution that most cases actually need.

Temporal and Cadence implement orchestrated sagas as durable workflows, with the state persisted so a crash resumes rather than restarts, and compensation expressed as ordinary code. Their existence is evidence that hand-rolled orchestration converges on a workflow engine.

Skeen and Stonebraker's work on non-blocking commit protocols is the theoretical basis for "no protocol can be non-blocking under a single coordinator failure with asynchronous communication", which is why 3PC's assumptions do not hold in practice.

The debate

The case for 2PC: genuine atomicity, no intermediate states, no compensation logic, and no isolation anomalies. Where it is available and the throughput cost is acceptable, it is simply correct and everything else is a workaround.

The case for sagas: no distributed locks, no blocking coordinator, each service stays autonomous, and it works across organisational and technology boundaries where 2PC cannot.

The case for neither, which is the one to raise: most operations described as needing a distributed transaction are one database write plus one event publish, which the transactional outbox solves with a single local transaction.

My position: outbox first, orchestrated saga when there is genuinely a multi-step business process, and 2PC only within a single database.

The outbox check comes first because most "we need a saga" conversations are actually the dual-write problem, and an outbox is a table plus a relay against a saga's orchestrator, compensation logic, semantic locks and intermediate-state handling. Asking "is this a multi-step business process with compensations, or one write and one publish" saves a large amount of complexity when the answer is the second.

When it genuinely is a saga, orchestration beyond about three steps, and the argument is operational: when it stalls at 3am, reading five services to reconstruct an event flow is a much worse position than reading the orchestrator's state for that saga id. Choreography's decoupling is real and it is worth less than debuggability.

The design decision I would spend the most time on is step ordering to put the irreversible action last. Authorise early and capture late, so a downstream failure voids an authorisation (invisible to the customer) rather than refunding a capture (visible, and a support call). That single reordering is available far more often than people assume and it is where the customer-visible difference lives.

And the property most descriptions omit: sagas give up isolation as well as atomicity. Another saga can read an intermediate state that is later compensated away, which is a dirty read across services. The countermeasures, semantic locks, commutative updates, reordering, are in the original 1987 paper and are routinely skipped, and the resulting anomalies are what surprise people in production.

Where I would push back on a requirement: if two steps are both irreversible and both must succeed or neither, no pattern here helps, and the answer is to redesign so at most one is irreversible. Saying that plainly is better than designing a saga whose compensation is "call support".

Follow-up Q&A

"Sagas or two-phase commit?" They give different guarantees, so it is not a preference. 2PC gives atomicity and takes availability: participants hold locks from prepare until commit, and if the coordinator dies between phases they are blocked, holding those locks, unable to abort unilaterally. A saga gives availability and takes atomicity and isolation: each step commits immediately, intermediate states are visible, and failures are handled by compensating transactions. Across service boundaries the saga is usually the only available option, because payment providers do not offer a prepare phase.

"Why is 2PC blocking, and does three-phase commit fix it?" It is blocking because a participant that has voted yes has durably promised it can commit and therefore cannot unilaterally abort, since the coordinator may already have told someone else to commit. So it waits, holding locks. Three-phase commit reduces blocking by adding a pre-commit phase, and it assumes synchronous communication with bounded message delay, which real networks do not provide. The underlying result is that no protocol can be non-blocking under a single coordinator failure with asynchronous communication, so it is a property rather than an implementation weakness.

"What is the difference between compensation and rollback?" Rollback restores the previous state exactly and nobody observes the intermediate. Compensation applies a new transaction that semantically undoes the previous one, and the intermediate state was visible. Charging a hundred dollars and refunding it is not a rollback: the customer saw both on their statement, and that is a support call. Sending an email cannot be compensated at all, only followed by a correction.

"Choreography or orchestration?" Orchestration beyond about three steps, and the argument is operational rather than architectural. With choreography the flow exists nowhere: understanding why a saga stalled means reading every participating service and reconstructing the event order. With orchestration it is one place, the state is queryable by saga id, and timeouts and retries are centralised. Choreography's decoupling is real and it is worth less than being able to debug at 3am.

"What's the most important design decision in a saga?" Step ordering, so the irreversible action is last. Authorise payment early and capture it late, so a shipping failure voids an authorisation, which the customer never sees, rather than refunding a capture, which they do and which generates a support call. That reordering is available far more often than teams assume, and it is where the customer-visible difference lives.

"What do sagas give up besides atomicity?" Isolation, and most descriptions omit it. Another saga can read a state that is later compensated away, which is a dirty read across services: one saga reserves five units and fails, and in between another reads stock and tells a customer the item is out. The countermeasures are in the original 1987 paper: semantic locks, commutative updates, reordering so the risky read follows the confirmed write, and re-reading a value before acting on it.

"When would you use neither?" When the actual problem is one database write plus one event publish, which is what most "we need a saga" conversations are. That is the dual-write problem and the transactional outbox solves it with a single local transaction: insert the row and the outbox entry together, and a relay publishes from the outbox at-least-once. That is a table and a relay against a saga's orchestrator, compensations, semantic locks and intermediate-state handling.

"What if two steps are both irreversible?" Then no pattern here helps, and I would say so rather than designing a saga whose compensation is "call support". The answer is to redesign so at most one step is irreversible, usually by moving one to a reversible form: authorise rather than capture, reserve rather than decrement, draft rather than send. If that genuinely is not possible, the operation needs a human in the loop for the failure case, and that should be designed rather than discovered.

"How do you handle a compensation that fails?" It retries with backoff, forever, and alerts after N attempts, because a failed compensation leaves the system inconsistent with no further automatic recovery. That means compensations must be idempotent, since they will be retried, and it means a saga stuck in compensation is an operational item with a named owner rather than a silent inconsistency. And the orchestrator persists saga state, so a crash resumes rather than restarting the whole flow.

Common misconceptions

"A saga is a distributed transaction." It is a sequence of local transactions with compensations. There is no atomicity and no isolation.

"Compensation is rollback." The intermediate state was visible and the compensation is itself an observable event. A refund is not an un-charge.

"3PC solves 2PC's blocking." It assumes synchronous communication with bounded delay, which real networks do not provide, and it is not used in practice.

"Sagas only give up atomicity." They give up isolation too, and dirty reads across services are the anomalies that surprise people.

"We need a saga." Usually the problem is one write plus one publish, which the transactional outbox solves with a single local transaction.

Interview delivery note

Frame it as different guarantees rather than different options, because that is the distinction being tested: "They're not alternatives with a preference. 2PC gives atomicity and takes availability: participants hold locks from prepare until commit, and if the coordinator dies between phases they're blocked holding those locks and can't unilaterally abort. A saga gives availability and takes atomicity and isolation."

Give the blocking result precisely, because it separates knowing the protocol from understanding it: "And that's a proved property rather than an implementation weakness: no protocol can be non-blocking under a single coordinator failure with asynchronous communication. Three-phase commit reduces it by assuming synchronous communication with bounded delay, which real networks don't give you, which is why it isn't used."

Make the compensation distinction concrete: "Compensation isn't rollback. Charging a hundred dollars and refunding it isn't an un-charge, the customer saw both on their statement and that's a support call. And sending an email can't be compensated at all."

Volunteer the design decision that matters most: "So the most important decision in a saga is step ordering, putting the irreversible action last. Authorise early, capture late, so a shipping failure voids an authorisation the customer never sees rather than refunding a capture they do. That's available more often than people assume."

The correction most descriptions need: "And sagas give up isolation as well as atomicity, which usually gets left out. One saga can read a state another saga later compensates away, which is a dirty read across services. The countermeasures are in the original 1987 paper: semantic locks, commutative updates, reordering."

Close with the check that saves the most work: "Though before designing one I'd ask whether this is genuinely a multi-step business process with compensations, or one database write plus one event publish. Most of the time it's the second, and that's the transactional outbox: a table and a relay, against a saga's orchestrator, compensations and intermediate-state handling."

Further reading

  • Garcia-Molina and Salem, "Sagas" (SIGMOD 1987), particularly the countermeasures for the isolation problem.
  • Chris Richardson, microservices.io, on the saga pattern (both styles) and the transactional outbox.
  • The X/Open XA specification, and Spanner's use of two-phase commit across Paxos groups as the case where 2PC works.
  • Temporal's documentation on saga implementation as durable workflows, for what hand-rolled orchestration converges on.