See It Flow

Core Concepts

Saga Pattern

Maintain business consistency across multiple services without distributed transactions.

Mangesh MandlikLast updated:

What Is the Saga Pattern?

Keeping a business operation coherent when no single transaction can span it

A saga coordinates one business operation that spans several services, as in a microservice architecture where each service owns its data and commits its own local transaction. If a later step fails, the saga runs compensating actions, new business operations that counteract the effect of the steps that already committed. The goal is to end in a valid business state, either completed or cleanly compensated. It does not give you the all-or-nothing visibility and isolation of one ACID transaction.

The pattern exists because a single transaction cannot reach across independently owned databases and third-party APIs. A hotel booking has to hold a room in an inventory database, authorize a card with an external payment provider, and confirm the reservation. Nothing lets a database commit and a card network commit together, so the operation is always, for some period, partly done. A saga makes that period explicit: it records how far the operation got, what was committed, and what to do from there.

The mental model: a saga is a durable to-do list with an undo plan for every item. The list survives crashes because it is stored, each item can be repeated safely because it is idempotent, and when an item fails for good the plan says how to reach a state the business accepts. Three kinds of questions follow from that, and the rest of this lesson answers them in order. What does each step commit, and how is it countered (compensation)? Who remembers where the operation is (the coordinator and its state)? What can other operations see in the meantime (isolation)?

Take the running example. A guest books a room. The saga holds the room, authorizes the payment, confirms the booking, and emails the guest. If the payment is declined, the saga releases the hold. If the payment call times out, the saga must not assume either outcome. It has to find out what actually happened, because releasing the room while the card was charged, or charging again while the first charge exists, are both business failures. Saga papers date to Garcia-Molina and Salem (1987), who proposed breaking long-lived transactions into sub-transactions with compensations; the microservices form is the same idea with networks in the middle.

Visual Story

Distributed transaction → step failure → compensation → eventual business consistency

Saga Pattern

Saga Pattern
Happy Path
0 / 0
❌Up nextStep Failure

⤢ Expand for synchronized narration timeline

A Booking Saga, End to End

The normal path and the two failures every design has to handle

Four participants take part. Booking owns the workflow and the saga record. Inventory holds and confirms rooms. Payment talks to a card provider. Notification emails the guest. Each owns its data; none can join another's transaction. The reservation reference booking_817 is created once, at the start, and travels with every command so that every participant can recognise a repeat.

Hotel booking saga: normal pathBooking asks Inventory to hold the room and Inventory commits the hold. Booking then asks Payment to authorize booking_817 and Payment commits the authorization. Booking asks Inventory to confirm, which is the pivot: the booking now exists. Booking finally asks Notification to email the guest and records COMPLETED. Marks: ✔ is a committed local transaction, ✘ is a failure, ↩ is a compensating operation.BookingorchestratorInventoryroom holdsPaymentcard providerNotificationguest email1. HoldRoom(booking_817)2. ✔ hold committed3. Authorize(booking_817)4. ✔ authorization committed5. ConfirmBooking6. ✔ booking confirmed (pivot)7. SendConfirmation8. ✔ email accepted9. state = COMPLETED
Hotel booking saga: normal path. Booking asks Inventory to hold the room and Inventory commits the hold. Booking then asks Payment to authorize booking_817 and Payment commits the authorization. Booking asks Inventory to confirm, which is the pivot: the booking now exists. Booking finally asks Notification to email the guest and records COMPLETED. Marks: ✔ is a committed local transaction, ✘ is a failure, ↩ is a compensating operation.

Reading the marks. ✔ a local transaction committed in that service · ✘ a definitive failure · ↩ a compensating operation, which is a new committed transaction, not an undo. Dashed arrows are replies.

Normal path. Hold, authorize, confirm, notify. Every arrow marked ✔ is a local transaction that has already committed in its own service; there is no moment at which all four exist together in one transaction. The confirmation is where the booking becomes real to the hotel, and later sections treat it as the point after which the saga only moves forward. The authorization is only a hold on the guest's funds; the capture that actually charges the card is a retriable step that runs after the pivot, so it is not drawn in the figure.

Payment declined. The provider answers definitively that the card was not authorized. The only committed effect is the room hold, so the compensation is a single ReleaseHold. Notice the compensation is itself a command with its own acknowledgement. The saga is not finished when it sends it, but when Inventory confirms the release, so the state RELEASING_HOLD exists between the decline and COMPENSATED.

Ambiguous timeout. The authorization committed at the provider, but the reply never arrived. From Booking's side this looks identical to a request that never arrived. A timeout is the absence of information, not a failure. Booking records PAYMENT_OUTCOME_UNKNOWN, asks the provider what happened to booking_817, and proceeds from the answer. Applying a failure handler here, releasing the room and telling the guest the booking failed, would leave an authorization on the card for a booking that does not exist. The pattern of "ask, don't guess" recurs in Retry & Backoff, which covers why timeouts are ambiguous in the first place.

Rollback Versus Compensation

A compensation is a new transaction, not a rewind

A database rollback discards changes that were never committed, so no other transaction ever saw them. A saga step has already committed and may have been observed, acted on and replicated. Its compensation is a separate business operation that attempts to counteract the earlier effect. The earlier effect remains in the history; the compensation adds to it.

That difference shows up in everything a compensation can and cannot do. Releasing a room hold restores capacity cleanly, because the hold was only a claim. A card authorization can usually be voided, which frees the held funds without a charge ever settling. A payment that was captured is different: the customer can see both a charge and a refund, fees may not come back, and settlement takes days. An email cannot be unsent, so the closest compensation is a second email, which is itself a visible event. Design by asking what an outside observer can see, not what the database says.

Forward stepCompensationWhat stays visible afterwards
Hold a room or inventory unitRelease the holdNothing external, if the hold never left your system. A displayed "sold out" seen meanwhile cannot be recalled.
Authorize a cardVoid the authorizationA pending line on the cardholder's statement can linger until the issuer releases it.
Capture a paymentRefundBoth the charge and the refund appear; fees and settlement timing may not reverse. Prefer authorizing first and capturing late.
Reserve a seat, apply a coupon, deduct pointsCancel, restore, credit backBalances are restored but other customers may have been refused in between, and a coupon may have expired.
Send a confirmation emailNone; at best a correction emailThe original message. Treat sending as irreversible and do it late.

The practical consequence is that every step needs more than a forward action. It needs an outcome that can be queried (so ambiguity can be resolved), an idempotency strategy (so repeats are harmless), and where one exists, a compensation that is itself safe to repeat. The Idempotency lesson covers how keys and result storage make that work; a saga uses the same mechanism at every hop.

Compensable, Pivot and Retriable Steps

Order steps by how reversible they are, and decide where the saga stops going backwards

Not every step deserves the same treatment. Sagas are easier to reason about when each step is one of three kinds. A compensable step has a compensation and can be undone: the room hold, the card authorization. A pivot step is the go/no-go point. If it commits, the saga will run to completion; if it fails, the saga compensates everything before it. A retriable step comes after the pivot, must eventually succeed, and is simply repeated until it does: capturing the payment, sending the email.

This gives the saga two recovery directions. Backward recovery runs compensations for the committed steps in the opposite order and is available only before the pivot commits. Forward recovery keeps retrying the remaining steps and is the only option after it. Which direction applies to a failure depends on where the failure lands relative to the pivot, not on how severe it feels. In the booking design here the pivot is confirming the reservation with the hotel: once it commits, a failed email or a slow capture does not undo the booking.

Ordering follows. Put steps that are easy to undo and likely to fail early, so most failures compensate only cheap things. Put the irreversible, externally visible steps (emails, shipments, captures) after the pivot, so they are only ever executed in sagas that will complete. Sending a confirmation email in step two of five means every later failure produces a customer who has been told yes and is about to be told no.

Booking saga when: no failureHold room: committed; Authorize payment: committed; Confirm booking: committed; Capture payment: committed; Send confirmation: committed. No compensation runs. Illustrative design: the first two steps are compensable, confirming the booking is the pivot, and the last two steps are retriable.FORWARD STEPSCOMPENSATIONS (REVERSE ORDER)Hold roomcompensablecommittedAuthorize paymentcompensablecommittedConfirm bookingpivotcommittedCapture paymentretriablecommittedSend confirmationretriablecommittedEvery step committed; no compensation is needed.
Booking saga when: no failure. Hold room: committed; Authorize payment: committed; Confirm booking: committed; Capture payment: committed; Send confirmation: committed. No compensation runs. Illustrative design: the first two steps are compensable, confirming the booking is the pivot, and the last two steps are retriable.
Outcome
Completed
Committed steps
5 of 5
Compensations run
0
Extra transactions
0
each a new commit

Pick each failure in turn. A sold-out room ends the saga with nothing to undo because no step had committed. A declined payment compensates one step; a rejected confirmation compensates two, in reverse. Failures after the pivot never compensate: the capture and the email are retried until they succeed, with the same idempotency key every time, and escalate to a person when a retry can never succeed, such as a card that cannot be captured after the booking is already confirmed. Whether retries should run in the request or from a durable queue with dead-letter handling is a policy choice, but the saga's state has to record that a retriable step is pending.

Orchestration Versus Choreography

Who knows the whole workflow: one coordinator, or nobody

There are two ways to drive the steps. In an orchestrated saga a coordinator owns the workflow state. It sends a command to a service, waits for the outcome, decides the next command, and starts compensation when something fails. The flow is written down in one place, which makes it readable, testable and observable. The price is that the coordinator's durable state and availability now have to be engineered. It should hold sequencing and decisions, not business rules that belong to the services, or it turns into a god service everyone must change.

Orchestrated saga: one coordinator owns the flowThe Booking orchestrator persists the saga state in its own durable store and sends commands to the services: HoldRoom, ConfirmBooking or ReleaseHold to Inventory, Authorize or Void to Payment, SendConfirmation to Notification. Each service replies with an outcome and the orchestrator decides the next command, including which compensations to send when a step fails. The services do not know about each other.Booking orchestratordecides next commandSaga statedurable, versionedInventoryHold · Confirm · ReleasePaymentAuthorize · VoidNotificationSendConfirmationpersistcommandcommandcommand
Orchestrated saga: one coordinator owns the flow. The Booking orchestrator persists the saga state in its own durable store and sends commands to the services: HoldRoom, ConfirmBooking or ReleaseHold to Inventory, Authorize or Void to Payment, SendConfirmation to Notification. Each service replies with an outcome and the orchestrator decides the next command, including which compensations to send when a step fails. The services do not know about each other.

In a choreographed saga there is no coordinator. Each service reacts to events: Inventory publishes InventoryHeld, Payment reacts by authorizing and publishes PaymentAuthorized, Booking reacts by confirming. A failure is another event: PaymentFailed makes Inventory release the hold. Services stay loosely coupled and there is no central component to scale or lose.

Choreographed saga: services react to each other's eventsFour services in a row. Inventory publishes InventoryHeld, which Payment consumes to authorize the card. Payment publishes PaymentAuthorized, which Booking consumes to confirm. Booking publishes BookingConfirmed, which Notification consumes to send the email. On failure, Payment publishes PaymentFailed, which Inventory consumes to release the hold. No service holds the whole workflow: it exists only as the subscription graph.Inventoryholds / releasesPaymentauthorizesBookingconfirmsNotificationemails guestInventoryHeldPaymentAuthorizedBookingConfirmedPaymentFailed → Inventory releases the hold
Choreographed saga: services react to each other's events. Four services in a row. Inventory publishes InventoryHeld, which Payment consumes to authorize the card. Payment publishes PaymentAuthorized, which Booking consumes to confirm. Booking publishes BookingConfirmed, which Notification consumes to send the email. On failure, Payment publishes PaymentFailed, which Inventory consumes to release the hold. No service holds the whole workflow: it exists only as the subscription graph.

The cost of choreography is that the workflow exists only as the sum of subscriptions. With three steps this is comfortable. With ten steps and a handful of exceptional branches, answering "why was this room released?" means reading every service's handlers. Three failure modes are typical. A compensation event is published and nobody subscribes, so the inventory stays held. Services end up in cyclic dependencies because each reacts to another's events. And no one can say whether a saga is stuck, since no component is responsible for noticing. Mitigations exist: put a correlation id (the saga id) on every event, build a read model that tracks each saga from the event stream, and set timeouts explicitly. The read model is, in effect, a coordinator that only observes.

Neither style is categorically better. Choreography fits short, stable flows owned by one or two teams, where the events are useful to other consumers anyway. Orchestration fits flows with branches, timeouts, human approval, or many compensations, and flows where the people debugging need one place to look. Mixed designs are common: an orchestrator inside one bounded context emits events to others. A workflow engine can provide the orchestrator's durable state, timers and retries instead of hand-built tables; Temporal, AWS Step Functions and Camunda are examples of that approach. The Pub/Sub and Queues & Workers lessons cover the delivery mechanics both styles depend on.

One distinction helps in either style. A command tells a specific service to do something and expects a reply (HoldRoom). An event states a fact that already happened and does not care who listens (InventoryHeld). Orchestration is built from commands and replies; choreography from events. Mixing the two vocabularies inside one style, say an event named PleaseReleaseHold, is a sign that the design has not decided who is in charge.

Build the Saga as a Durable State Machine

Persisted progress is what makes recovery possible

A robust saga has an identifier and a persisted state record, and every change to that record is a legal transition. The booking's forward path is CREATED → INVENTORY_HELD → PAYMENT_AUTHORIZED → BOOKING_CONFIRMED → COMPLETED. A known payment failure leads to RELEASING_HOLD and then COMPENSATED. An ambiguous response leads to PAYMENT_OUTCOME_UNKNOWN and RECONCILING. The state names matter more than they look: they are what a recovery worker, an alert and a support engineer will read.

Saga state machine for the bookingTop row, the forward path: CREATED, INVENTORY_HELD, PAYMENT_AUTHORIZED, BOOKING_CONFIRMED, COMPLETED. From INVENTORY_HELD a declined payment goes to RELEASING_HOLD and then COMPENSATED; a timeout goes to PAYMENT_OUTCOME_UNKNOWN and then RECONCILING. From RECONCILING, finding the authorization returns to PAYMENT_AUTHORIZED, while finding none and deciding by policy to give up goes to RELEASING_HOLD. From PAYMENT_AUTHORIZED a definitive confirmation rejection goes to VOIDING_PAYMENT and then RELEASING_HOLD. If the first step is rejected, the saga ends without compensation and that terminal state is not drawn.timeoutfoundnot found, policy: give upconfirm rejectedvoid donedeclinedrelease committedCREATEDINVENTORY_HELDPAYMENT_AUTHORIZEDBOOKING_CONFIRMEDCOMPLETEDPAYMENT_OUTCOME_UNKNOWNRECONCILINGVOIDING_PAYMENTRELEASING_HOLDCOMPENSATED
Saga state machine for the booking. Top row, the forward path: CREATED, INVENTORY_HELD, PAYMENT_AUTHORIZED, BOOKING_CONFIRMED, COMPLETED. From INVENTORY_HELD a declined payment goes to RELEASING_HOLD and then COMPENSATED; a timeout goes to PAYMENT_OUTCOME_UNKNOWN and then RECONCILING. From RECONCILING, finding the authorization returns to PAYMENT_AUTHORIZED, while finding none and deciding by policy to give up goes to RELEASING_HOLD. From PAYMENT_AUTHORIZED a definitive confirmation rejection goes to VOIDING_PAYMENT and then RELEASING_HOLD. If the first step is rejected, the saga ends without compensation and that terminal state is not drawn.

Two details of the diagram carry the design. RECONCILING has two exits: finding the authorization returns the saga to PAYMENT_AUTHORIZED and finding none leads to compensation, so ambiguity is always resolved by evidence and never by a timer alone. And a failure after authorization, for example the hotel definitively rejecting the confirmation, passes through VOIDING_PAYMENT before the hold is released, because the order of compensations follows what each one depends on (see below). Compensating states such as RELEASING_HOLD are real states with real retries, not instantaneous.

Why a timeout is not a failure

If the provider processed the request and the response was lost, issuing a new payment with a different idempotency key charges the card twice. A recovery worker should instead query the provider using the original payment reference, or repeat the request with the same provider-supported idempotency key, within the provider's documented retention window for such keys. Providers differ on how long keys are honoured and on whether a lookup by reference exists, so both belong in the integration design and tests, not in assumptions.

Persisting the state and the saga record

The saga record needs enough to resume: identity, type, state, a version for concurrency control, and the context the remaining steps need (the quote accepted, the hold id, the payment reference). A scheduling field lets a worker find sagas that are due for another look.

Saga tables (PostgreSQL, runnable)
-- PostgreSQL. Runnable as written (needs 9.5+ for ON CONFLICT, jsonb needs 9.4+).
CREATE TABLE saga_instance (
  saga_id        text PRIMARY KEY,             -- e.g. 'booking_817'
  saga_type      text        NOT NULL,
  state          text        NOT NULL,
  version        integer     NOT NULL DEFAULT 0,
  context        jsonb       NOT NULL,         -- quote id, hold id, payment reference ...
  next_action_at timestamptz,                  -- when a recovery worker should look again
  updated_at     timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE saga_outbox (
  id          bigserial PRIMARY KEY,
  saga_id     text        NOT NULL REFERENCES saga_instance (saga_id),
  command_id  text        NOT NULL UNIQUE,     -- deterministic per saga step, used for dedupe
  destination text        NOT NULL,
  payload     jsonb       NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  sent_at     timestamptz
);

Two workers can pick up the same saga: a message is redelivered, or a recovery worker races the normal reply path. Make every transition a compare-and-set on (state, version). The loser sees zero rows updated, re-reads, and discovers the work is already done. That is deliberate optimistic concurrency on the saga row, and it is usually all the mutual exclusion a saga needs. Reaching for a lease or a distributed lock here brings its own hazards, because a paused worker can still act after its lease expired; see Distributed Locks for leases and fencing tokens, and why the check belongs at the resource being protected, which here is the saga row.

Orchestrator logic

Reply handling and the recovery worker (illustrative pseudocode)
# Illustrative pseudocode, not a library API.
# transition(saga, from_state, to_state, outgoing) is the single transaction shown above:
# compare-and-set on (state, version) plus the outbox insert.

on PaymentReply(saga_id, reply):
    saga = load(saga_id)
    if saga.state != INVENTORY_HELD and saga.state not in (PAYMENT_OUTCOME_UNKNOWN, RECONCILING):
        return                                    # duplicate or stale reply: ignore

    if reply == AUTHORIZED:
        transition(saga, to=PAYMENT_AUTHORIZED, send=ConfirmBooking(saga.id))
    elif reply == DECLINED:                       # a definitive answer, nothing was charged
        transition(saga, to=RELEASING_HOLD, send=ReleaseHold(saga.id))
    elif reply == TIMEOUT:                        # NOT a failure
        transition(saga, to=PAYMENT_OUTCOME_UNKNOWN, next_action_at=now() + 30s)

every 30 seconds:                                 # recovery worker
    for saga in due(states=[PAYMENT_OUTCOME_UNKNOWN, RECONCILING]):
        result = payment.query(reference=saga.id)  # or retry with the same idempotency key
        if result.found:        transition(saga, to=PAYMENT_AUTHORIZED, send=ConfirmBooking(saga.id))
        elif result.not_found and policy_allows_giving_up(saga):
                                transition(saga, to=RELEASING_HOLD, send=ReleaseHold(saga.id))
        else:                   reschedule(saga, backoff(saga.attempts))   # still unknown, keep going

Not runnable. It shows the decision structure: stale replies are ignored, a timeout becomes a state rather than an error, and recovery queries evidence before choosing a direction.

Atomic Progress and Idempotent Steps

The coordinator can crash between any two lines; every step must still be safe

A saga's progress has two parts: the new state in its store and the next command sent to a service. If they are separate operations, a crash between them corrupts the saga. Commit PAYMENT_AUTHORIZED and crash before publishing the confirm command, and the saga sits in that state forever with nothing driving it. Publish first and crash before committing, and after restart the saga is still in the old state and publishes again.

The usual fix is a transactional outbox: write the state change and the outgoing command to the same database in one local transaction, and let a separate relay publish committed rows. That removes the lost-command case and leaves one deliberate one, a duplicate when the relay crashes after publishing but before marking the row sent. The Outbox Pattern lesson owns the relay mechanics; the saga-specific part is below.

State change plus command in one transaction (PostgreSQL, runnable)
-- One local transaction: advance the state AND record the next command.
BEGIN;

UPDATE saga_instance
   SET state = 'PAYMENT_AUTHORIZED', version = version + 1, updated_at = now()
 WHERE saga_id = 'booking_817'
   AND state   = 'INVENTORY_HELD'
   AND version = 1;
-- Application: expect exactly one row. Zero rows means another worker already advanced this
-- saga, or this is a replayed reply. ROLLBACK, re-read the saga, and decide from its real state.

INSERT INTO saga_outbox (saga_id, command_id, destination, payload)
VALUES ('booking_817', 'booking_817:confirm', 'inventory',
        '{"op": "ConfirmBooking", "bookingId": "booking_817"}');

COMMIT;

-- Separate relay process: publish unsent rows, then mark them sent.
-- A crash between publish and UPDATE means the row is published again later (at-least-once).
SELECT id, destination, command_id, payload
  FROM saga_outbox
 WHERE sent_at IS NULL
 ORDER BY id
 LIMIT 100
 FOR UPDATE SKIP LOCKED;
State change and command committed together, duplicate tolerated downstreamFour actors: Orchestrator, Saga DB, Relay, Inventory service. The orchestrator commits the new saga state and an outbox row for command 41 in one local transaction. The relay reads the unsent row and delivers ConfirmBooking for command 41 to the Inventory service, then crashes before marking the row as sent. After restart it finds the row still unsent and delivers it again. The Inventory service has already seen command 41 and ignores the duplicate. Without the outbox, a crash between committing the state and publishing the command would lose the command entirely.OrchestratorSaga DBstate + outboxRelayoutbox publisherInventory serviceidempotent consumer1. tx: state + outbox row 412. ✔ both committed3. read unsent rows4. ConfirmBooking(cmd 41)5. crash before marking row sent6. after restart: row 41 unsent7. ConfirmBooking(cmd 41) again8. cmd 41 seen: no-op
State change and command committed together, duplicate tolerated downstream. Four actors: Orchestrator, Saga DB, Relay, Inventory service. The orchestrator commits the new saga state and an outbox row for command 41 in one local transaction. The relay reads the unsent row and delivers ConfirmBooking for command 41 to the Inventory service, then crashes before marking the row as sent. After restart it finds the row still unsent and delivers it again. The Inventory service has already seen command 41 and ignores the duplicate. Without the outbox, a crash between committing the state and publishing the command would lose the command entirely.

Duplicates are therefore expected, which makes consumers responsible for idempotency. Give each command a deterministic id derived from the saga and the step, such as booking_817:confirm. The service records the ids it has applied, in the same transaction as its own change, and answers a repeated id with the original outcome. This applies to compensations as much as to forward steps. A refund that is retried after a transient failure and succeeds twice is the same bug as a double charge. Compensations that fail permanently go to a retry-with-backoff policy, and once retries are exhausted to a dead-letter queue and an operator, with the saga marked as needing manual intervention rather than quietly abandoned.

Compensation Order and Dependency Constraints

Reverse order is a good default, not a law

Compensations usually run in the reverse order of the completed steps, because later actions often depend on resources created by earlier ones. You do not want to release a room while a payment that references it is still live and a guest might be told the booking exists. Reverse order is a heuristic, though. The correct sequence depends on business invariants and on which effects are irreversible. A cancellation may need an authorization reversed before inventory is released, or the opposite, depending on contract terms and on how a failure halfway through would be repaired. For a workflow that sends an email, reserves inventory and issues a refund, reverse order may be wrong simply because the email cannot be unsent and the others can.

Define each compensation alongside its forward action during design, not after the first incident. For every pair ask a short list of questions. Can the compensation fail, and what happens then? Is it idempotent? How long may it be retried before a person looks? Can it run when the forward action has not arrived yet? The last question is the one most designs miss. Messages can be reordered or delayed, so a ReleaseHold may reach Inventory before the HoldRoom it cancels. If the release finds nothing and returns success, the late hold then takes effect and nothing ever releases it. The robust form records a tombstone for the reservation id, so the late forward command is refused. The SQL under isolation below shows one.

A compensating transaction may itself need a saga-like retry-and-escalation policy. The recurring failure shapes are worth designing against explicitly:

  • No compensation defined. A later step fails, a card is charged, a room is held, nothing was ever written to counter them. Cleanup becomes manual and permanent.
  • Compensation fails. The refund call errors because the provider is down. The saga now sits between a failed forward step and a finished compensation, and the customer is out of pocket. It needs retries with backoff, an alert, and a manual path.
  • Duplicate compensation. The retry succeeds after the first attempt secretly already had, and the customer is refunded twice. The fix is the idempotency key described above.
  • Irreversible action too early. A confirmation email goes out at step two of five and the saga is cancelled at step four. The fix is ordering around the pivot, not a cleverer compensation.
  • Long-running inconsistency. Compensation takes twenty minutes of retries. For that time the customer sees a charge with no order. Surface the intermediate state to users and support ("your payment is being reversed") instead of hiding it.
  • Hidden choreography failure. A compensation event is emitted and nothing consumes it, so inventory stays held. Alert on sagas that stop progressing, not only on exceptions.

Isolation Anomalies and Concurrent Sagas

Sagas keep atomicity, consistency and durability in a weaker sense, and give up isolation

Sagas are often summarised as ACID without the I. Eventually every step completes or is compensated (atomicity in a business sense), each local transaction respects its own invariants, and the outcomes are durable. What is gone is isolation: while a saga is in flight, its intermediate effects are committed and visible to every other transaction and saga. Other work can read them, act on them, or overwrite them, and then the saga may compensate.

Two sagas read one free room and both claim itSaga A reads Inventory and sees one room available. Saga B reads Inventory and also sees one. Saga A writes available = 0 and Saga B writes available = 0. Both sagas believe they hold the last room, so one room is promised twice. Neither saga did anything wrong locally; the read and the write were separate steps with nothing guarding the gap. A guarded conditional update in the Inventory service closes this gap.Saga AInventoryavailable = 1Saga B1. read available2. 13. read available4. 15. write available = 06. write available = 07. one room, two promises
Two sagas read one free room and both claim it. Saga A reads Inventory and sees one room available. Saga B reads Inventory and also sees one. Saga A writes available = 0 and Saga B writes available = 0. Both sagas believe they hold the last room, so one room is promised twice. Neither saga did anything wrong locally; the read and the write were separate steps with nothing guarding the gap. A guarded conditional update in the Inventory service closes this gap.

The first scenario is the classic lost update. Two customers start booking the last room. Inventory reads "one available" for each, and then each decrements it. If the read and the decrement are separate steps with nothing between them, both sagas proceed and one room is promised twice. Compensation is not a substitute for protecting a local invariant: it would give one customer their money back after the apology email, but the oversell was avoidable. The guard belongs in the service that owns the data, as a single atomic conditional update with a stable reservation id and an expiry.

Guarded hold and idempotent release (PostgreSQL, runnable)
-- PostgreSQL. Runnable as written; the branching comments describe application logic.
CREATE TABLE room_night (
  room_type text    NOT NULL,
  night     date    NOT NULL,
  total     integer NOT NULL,
  held      integer NOT NULL DEFAULT 0,
  PRIMARY KEY (room_type, night),
  CHECK (held BETWEEN 0 AND total)
);

CREATE TABLE room_hold (
  hold_id    text PRIMARY KEY,                 -- the saga's stable reservation id
  room_type  text        NOT NULL,
  night      date        NOT NULL,
  status     text        NOT NULL CHECK (status IN ('HELD', 'CONFIRMED', 'RELEASED')),
  expires_at timestamptz NOT NULL
);

-- Forward step: HoldRoom. Idempotent on hold_id, guarded against overselling.
BEGIN;
INSERT INTO room_hold (hold_id, room_type, night, status, expires_at)
VALUES ('booking_817', 'deluxe', '2026-11-02', 'HELD', now() + interval '15 minutes')
ON CONFLICT (hold_id) DO NOTHING;
-- 0 rows inserted: a retry, or a release arrived first. Re-read room_hold.status, answer from it, stop.

UPDATE room_night
   SET held = held + 1
 WHERE room_type = 'deluxe' AND night = '2026-11-02'
   AND held < total;
-- 0 rows updated: sold out. ROLLBACK (this also removes the hold row) and reply SOLD_OUT.
COMMIT;

-- Compensation: ReleaseHold. Idempotent, refuses to undo a confirmed booking.
BEGIN;
UPDATE room_hold SET status = 'RELEASED'
 WHERE hold_id = 'booking_817' AND status = 'HELD'
RETURNING room_type, night;
-- 1 row returned: give the capacity back (next statement). 0 rows: already released, confirmed,
-- or the hold never arrived. In the last case record a tombstone so a late HoldRoom is refused:
--   INSERT INTO room_hold VALUES ('booking_817', 'deluxe', '2026-11-02', 'RELEASED', now())
--   ON CONFLICT (hold_id) DO NOTHING;
UPDATE room_night SET held = held - 1
 WHERE room_type = 'deluxe' AND night = '2026-11-02';   -- run only when the UPDATE above returned a row
COMMIT;

The CHECK constraint and the held < total predicate each stop overselling independently. The reservation id makes retries idempotent, and the tombstone refuses a hold that arrives after its own compensation.

The second scenario survives a perfectly guarded update. Saga A holds the last room. Saga B is correctly told that nothing is left. Then A's payment is declined and the hold is released, so a room existed after all, but B has already told its guest otherwise. This is a dirty read of a business state that was never final. A saga cannot prevent it by locking, because there is no cross-service lock. It is handled by designing the visible states, using what are called countermeasures:

CountermeasureIdeaBooking example
Semantic lockMark a record as being changed by an unfinished saga; others wait, fail or branch on the flag.Report the room as HELD, not sold. B offers a waitlist instead of "sold out". The flag is cleared by confirmation or release.
Commutative updatesDesign operations whose order does not matter, so interleaving cannot lose an update.Increment and decrement a counter, never write an absolute available = 0.
Pessimistic viewReorder steps so that a dirty read is least harmful.Perform the step that risks being compensated (the hold) first and the step that others would act on last.
Reread valueBefore overwriting, read the value again and abort if it changed. Optimistic concurrency.Update the price or quote only if its version still matches what the saga accepted.
Version fileRecord each operation so out-of-order compensations can be reconciled.Keep a per-reservation log so a late ReleaseHold is recognised against its hold.
By valueChoose the mechanism per request by business risk.Low-value holds use the cheap path; high-value or contested ones take a stricter semantic lock.

The right amount depends on cost. A dirty read that shows a customer "sold out" for a few seconds may be an acceptable lost sale. One that lets a trade settle against funds that are later reversed is not.

Changing prices, expiring holds, and what a timeout means

Long-running sagas meet changing prices, expired holds, and updated permissions. Record the quote or version the customer accepted when the saga starts, and decide in advance whether the saga may reprice or must fail. Give holds an explicit expiry so an abandoned saga cannot keep capacity forever, with a sweeper that releases expired holds through the same idempotent release path. And keep two kinds of timeout apart in the code and in the state names. A transport timeout means a call did not return, and the outcome is unknown. A business expiry means the deadline for the deal has passed, and the saga should stop and compensate. Treating the first as the second compensates things that did happen. Treating the second as the first retries a hold that no longer makes sense.

Saga Versus Two-Phase Commit

Atomic commit with blocking, or short local transactions with visible intermediate states

Two-phase commit (2PC) coordinates transactional resource managers through a prepare phase and a commit phase. Each participant durably promises it can commit and keeps its locks while it waits; the coordinator logs a decision and tells everyone. Within the model it supports, the result is atomic commit. The costs are structural. A participant that has voted yes cannot decide alone, so if the coordinator fails after the votes the prepared participants block, holding locks, until recovery. Every participant must implement the protocol, which excludes arbitrary HTTP APIs and external payment providers, and every participant is operationally coupled to the others. Some databases use 2PC internally across their own partitions, with the coordinator made highly available; that is a different proposition from spanning independent products and vendors.

How long database locks are held: two-phase commit versus a sagaIllustrative timeline in milliseconds for the same two-step booking. Under two-phase commit, Inventory's row lock is held from 0 to 150 ms and Payment's from 10 to 150 ms, because both participants stay prepared until the coordinator's decision arrives. In the saga, Inventory's local transaction holds its lock for 0 to 20 ms and Payment's for 30 to 50 ms. Between steps the room is only marked held, a business state other sagas can see, and no database lock is open. That visible intermediate state is what the saga buys its short locks with.02050100150ms (illustrative)2PC · Inventory row lock150 ms2PC · Payment row lock140 msSaga · Inventory local tx20 msSaga · room visible as “held”business state, no lockSaga · Payment local tx20 ms
How long database locks are held: two-phase commit versus a saga. Illustrative timeline in milliseconds for the same two-step booking. Under two-phase commit, Inventory's row lock is held from 0 to 150 ms and Payment's from 10 to 150 ms, because both participants stay prepared until the coordinator's decision arrives. In the saga, Inventory's local transaction holds its lock for 0 to 20 ms and Payment's for 30 to 50 ms. Between steps the room is only marked held, a business state other sagas can see, and no database lock is open. That visible intermediate state is what the saga buys its short locks with.

A saga never holds a cross-service transaction open. Each local transaction commits quickly, and the consistency between them is repaired afterwards. The price is the one this lesson has spent most of its length on: intermediate states are visible, compensations are semantic rather than physical, and isolation is weaker. A saga does not give external observers all-or-nothing visibility; it gives them a business process that eventually converges.

The decision starts with the boundary. If all the data lives in one database and one transaction can enforce the invariant, use that transaction. It is simpler and stronger than any saga. Reach for a saga when the workflow spans independently committed systems you cannot or should not enlist in a shared transaction, and when the business can define what "compensated" means for each step.

Failure Scenario: Payment Succeeded, Response Lost

A constructed scenario: walking one ambiguous failure through recovery, and what to monitor

The booking saga is in INVENTORY_HELD. It calls the payment provider with the reference booking_817, and the provider authorizes the charge. The network fails before the response arrives. The orchestrator records PAYMENT_OUTCOME_UNKNOWN, not a failure. A reconciliation worker then queries the provider for booking_817:

  • The authorization exists. The saga moves to PAYMENT_AUTHORIZED and proceeds to confirmation.
  • The provider reports none. The saga may retry under the same idempotency reference or release the hold, according to policy. One hazard deserves care: a request that was still in flight when the query ran can land after it. Retrying with the same key makes that harmless because the provider dedupes. Giving up and releasing the hold is only safe if the provider is guaranteed to reject a late arrival, or if a later check for the reference is part of the policy and any authorization found afterwards is voided.
  • The provider stays unavailable. The saga remains in a recoverable pending state and keeps retrying with backoff, and an escalation policy hands it to a person.
Reconciling an unknown payment outcome while the hold ticks downIllustrative timeline in seconds. The room hold is valid for 900 s. The authorize call times out at 10 s. Reconciliation queries start at 30 s and back off by doubling: 30, 90, 210, 450 and 930 s. The provider is unreachable for the first four. A stuck-saga alert fires at 600 s. The hold expires at 900 s, but the saga is not finished: the payment outcome is still unknown. The fifth query at 930 s finds the authorization, and because the hold is gone the saga voids it instead of confirming. Expiry of the hold bounds the inventory risk, not the payment ambiguity.03006009001200seconds since the authorize call started (illustrative)Inventory hold validexpires 900 sAuthorize calltimeoutQuery 1 · provider downQuery 2 · provider downQuery 3 · provider downQuery 4 · provider downStuck-saga alertpageHold expiresQuery 5 · found → voidvoid
Reconciling an unknown payment outcome while the hold ticks down. Illustrative timeline in seconds. The room hold is valid for 900 s. The authorize call times out at 10 s. Reconciliation queries start at 30 s and back off by doubling: 30, 90, 210, 450 and 930 s. The provider is unreachable for the first four. A stuck-saga alert fires at 600 s. The hold expires at 900 s, but the saga is not finished: the payment outcome is still unknown. The fifth query at 930 s finds the authorization, and because the hold is gone the saga voids it instead of confirming. Expiry of the hold bounds the inventory risk, not the payment ambiguity.

The timeline separates two bounds that are easy to conflate. The hold's expiry bounds the inventory risk: after 900 seconds nobody is blocked by a room nobody will pay for. It does not bound the payment ambiguity. The authorization may exist at the provider long after the hold is gone, so the saga must keep reconciling until it learns the outcome, and then void the authorization instead of confirming a booking whose room was released. Stopping at hold expiry and marking the saga failed is how orphaned authorizations appear.

What to monitor

Track the age of sagas by state and alert when a state exceeds its threshold. Also watch the compensation success rate, the count of ambiguous outcomes and how long they take to resolve, the duplicate command rate, and mismatches found by business reconciliation. Prefer alerts that state a business invariant over alerts on exceptions: "an authorized payment without a confirmed booking for 15 minutes" finds a stuck saga that no exception ever fired for. Put the saga id on every log line, trace span and message so one operation can be followed across services; with choreography this is the only way to reconstruct the flow.

When to Use Sagas, and When Not To

Partial completion has to be unavoidable and compensation has to be definable

Use a saga for multi-step orders, bookings, fulfilment, subscription changes and other workflows that span services where partial completion cannot be avoided and the business can state what a compensation is. A good saga design makes intermediate states explicit to engineers and, where it matters, to users.

SituationVerdictReason
Order, payment, inventory and shipping in separate servicesGood fitThe canonical case: independent commits, defined compensations, long-ish duration.
Travel or hotel booking with several suppliersGood fitPartial bookings must be released explicitly, and supplier calls are not transactional.
Money movement across systemsPossible, with careA saga does not guarantee both legs happen or neither does at any instant. Prefer one ledger and one transaction where possible, and add reconciliation either way.
All data in one databaseDo not useA normal transaction is simpler and stronger.
Publishing an event with no undo logicOverkillIf nothing needs compensating, a pub/sub or queue is enough.
Steps with no defined compensation and no pivotRedesign firstWithout a way to reach a valid end state, the saga only automates the mess.

Where to Go Next

The mechanisms a saga is built from

The first thing to get right is the step-level guarantees: the Idempotency lesson covers keys and stored results, and Retry & Backoff covers how to repeat a step without making an outage worse. To make state and commands commit together, read the Outbox Pattern; for stuck compensations, Dead-Letter Queues. When a saga needs exclusive ownership of a resource, Distributed Locks explains leases and fencing and why they are not a substitute for an invariant. Sagas often produce the events that feed CQRS read models and an Event Sourcing log, which are independent ideas that combine well with them.

Primary Sources

Specifications and documentation behind the claims on this page

  • Garcia-Molina and Salem, “Sagas” (ACM SIGMOD, 1987) — the original paper (no link given)