What a saga is and why distributed transactions need one

A saga is an ordered sequence of local transactions. Each step runs inside one service, commits to that service's own datastore, and then triggers the next step. If a later step fails, the saga runs compensating transactions for the steps that already committed, in reverse order. A compensation is not a rollback. The original commit is permanent and other readers may already have seen it. The compensation is a new change that semantically undoes the effect: a refund for a charge, a stock release for a reservation, a cancellation for an order.

The need comes from database-per-service. When each service owns its data, an operation that touches several services, such as creating an order, reserving stock and charging a card, cannot be wrapped in one local ACID transaction. No single database holds all of that state.

Two-phase commit is the textbook answer, and teams usually avoid it for concrete reasons:

  • It requires a coordinator and participants that implement the protocol.
  • Participants hold locks from prepare until commit, across network round trips. They can stay blocked if the coordinator fails mid-protocol.
  • The operation completes only when every participant is available, so their availability becomes coupled.
  • Many message brokers and datastores do not take part in XA-style transactions at all.

Treat a saga as a cost you accept when ACID is not available, not as a default. Where the design allows it, prefer ACID over BASE. This article is for backend and platform engineers whose services own separate datastores and whose business operations cross those service boundaries.

How a saga works: an order flow step by step

Consider an order placed against three services: Order, Inventory and Payment. Each step is a local transaction in one service's own database, and each publishes the result that triggers the next step.

Happy path: Order inserts the order as PENDING. Inventory reserves the items. Payment authorizes the card. Order flips the order to CONFIRMED.

Failure at payment: the card is declined. No step can be rolled back, because each has already committed. The saga instead runs compensations in reverse order. Inventory releases the reservation, and Order marks the order REJECTED. The record of the attempt stays. It is not deleted.

Compensations are semantic. They reverse the business effect, not the bytes. A refund is a new transaction that the customer sees on a statement, not an un-charge. A confirmation email cannot be unsent. The only way to compensate for it is to send a follow-up email.

Step Service Type On failure later
Create order (PENDING) Order Compensatable Mark REJECTED
Reserve inventory Inventory Compensatable Release reservation
Authorize payment Payment Pivot Saga commits or aborts here
Confirm order Order Retriable Retry until it succeeds

The pivot is the go/no-go point. Before it, every step must have a compensation. After it, steps cannot fail for business reasons. They must be safe to retry until they succeed. This is why ordering matters. Put cheap, easily reversed steps first, and put the step most likely to reject the order at the pivot. Push actions that are hard to undo, such as emails, shipping and charge capture, past the pivot so they never need compensating.

 HAPPY PATH  (each box = one local transaction in that service's own DB)

 +-------------+   +-------------+   +-------------+   +-------------+
 | 1 Order     |-->| 2 Inventory |-->| 3 Payment   |-->| 4 Order     |
 | create      |   | reserve     |   | authorize   |   | confirm     |
 | PENDING     |   | items       |   | card        |   | CONFIRMED   |
 +-------------+   +-------------+   +-------------+   +-------------+
  compensatable     compensatable        PIVOT            retriable
 |<---- needs a compensation --->|   |  go / no-go  |   |<--- retry -->|

 Hard-to-undo actions (email, shipping, charge capture) go after the pivot.

 FAILURE AT THE PIVOT  (card declined): compensate in reverse order

 +-------------+   +-------------+   +-------------+
 | C2 Order    |<--| C1 Inventory|<--| 3 Payment   |
 | mark        |   | release     |   | authorize   |
 | REJECTED    |   | reservation |   | DECLINED    |
 +-------------+   +-------------+   +-------------+
  record kept,      semantic undo,    nothing rolls
  not deleted       not a rollback    back: saga aborts

An order saga's happy path through Order, Inventory and Payment, with payment authorization as the pivot, and the reverse-order semantic compensations that run when the card is declined.

Orchestration vs choreography

With the steps and their order settled, something has to drive them. There are two ways to do that.

With orchestration, a saga orchestrator owns the flow. It sends a command to each participant ("reserve stock", "authorize payment") and waits for the reply. It records progress in its own saga state, and when a step fails it sends compensating commands in reverse order. Participants don't know the saga exists. They just handle commands.

With choreography, there is no coordinator. Each service subscribes to domain events and reacts to them. Order publishes OrderCreated, Inventory reserves stock and publishes StockReserved, and Payment acts on that. Compensation runs on events too: PaymentFailed makes Inventory release the stock.

Concern Orchestration Choreography
Coupling Orchestrator depends on every participant's API Services depend on each other's events
Visibility and debugging Flow is written down in one place Flow has to be pieced together from event logs
Cyclic event dependencies Unlikely, because commands go one way A real risk as more subscribers are added
Saga state Stored by the orchestrator Spread across participants' records
Testing Test the orchestrator alone with mocked replies Needs several services running together
Changing the flow Edit one component Coordinated changes across services

Each approach has a cost. An orchestrator is one more service to deploy and monitor. It can also become a god service if business rules creep into it, so keep it limited to sequencing and leave decisions to the participants. Choreography's flow exists only in the subscriptions, so every added step makes it harder to answer "what happens after X?"

Choreography works for two or three steps owned by one team. Use orchestration once a saga has four or more steps or spans services owned by different teams. It gives those teams one place to agree on the flow and one place to debug it.

Implementing it: outbox, idempotency and saga state

Each saga step changes its own service's database and also tells the next participant what happened. When those are two separate operations, a crash between them causes a problem. You can end up with an order marked PAID but no event ever published. Or an event goes out for a payment that later rolled back. This is the dual-write problem. Retries can't fix it, because neither side knows whether the other operation succeeded.

Transactional outbox

Write the business change and the outgoing message in the same local transaction: update payments and insert a row into an outbox table with columns for message ID, destination, payload and creation time. A separate relay reads the unpublished rows, publishes them and marks them sent. The database's atomicity now covers both writes.

The relay can crash after publishing a row but before marking it sent, so delivery is at-least-once. That means every step and every compensation handler must be idempotent. One way to do this is to insert the incoming message ID into a processed_messages table that has a unique constraint. Do the insert in the same transaction as the handler's work. When a duplicate arrives, the insert fails and the handler skips the message.

Late and out-of-order messages

A CancelPayment can arrive before the ChargePayment it undoes. In that case the compensation records a tombstone for the saga ID. When the charge arrives later, its handler finds the tombstone and ignores the charge.

Saga state

An orchestrator stores one row per saga:

Column Purpose
saga_id Primary key, also the correlation ID
current_step Last step dispatched
status RUNNING, COMPENSATING, COMPLETED, FAILED
version Optimistic lock against concurrent replies
updated_at Lets you find sagas that have stopped making progress

Update this row in the same transaction as the orchestrator's outbox insert, so the state change and the next command are committed together.

-- Outbox, idempotency and saga-state tables (generic SQL)
CREATE TABLE outbox (
  message_id  VARCHAR(64) PRIMARY KEY,
  destination VARCHAR(100) NOT NULL,
  payload     TEXT NOT NULL,
  created_at  TIMESTAMP NOT NULL,
  sent_at     TIMESTAMP NULL          -- NULL until the relay publishes the row
);
CREATE TABLE processed_messages (
  message_id   VARCHAR(64) PRIMARY KEY, -- a duplicate delivery violates this key
  processed_at TIMESTAMP NOT NULL
);
CREATE TABLE saga_state (
  saga_id      VARCHAR(64) PRIMARY KEY, -- also the correlation ID
  current_step VARCHAR(50) NOT NULL,
  status       VARCHAR(20) NOT NULL,    -- RUNNING, COMPENSATING, COMPLETED, FAILED
  version      INTEGER NOT NULL,
  updated_at   TIMESTAMP NOT NULL
);

-- Payment service handling ChargePayment (message msg-7f3a, saga saga-1042)
BEGIN;
-- Fails on redelivery: the handler rolls back and acks the message as a no-op
INSERT INTO processed_messages VALUES ('msg-7f3a', CURRENT_TIMESTAMP);
-- If CancelPayment arrived first it left a CANCELLED tombstone row, so 0 rows change
UPDATE payments SET status = 'PAID'
 WHERE saga_id = 'saga-1042' AND status = 'PENDING';
-- Emit the reply only if the charge actually applied; same transaction as the update
INSERT INTO outbox (message_id, destination, payload, created_at, sent_at)
SELECT 'msg-9c21', 'saga-orchestrator',
       '{"saga_id":"saga-1042","event":"PaymentCharged"}', CURRENT_TIMESTAMP, NULL
  FROM payments WHERE saga_id = 'saga-1042' AND status = 'PAID';
COMMIT;

-- Orchestrator advancing the saga: 0 rows updated means a concurrent reply won; reload and retry
UPDATE saga_state
   SET current_step = 'RESERVE_STOCK', version = version + 1, updated_at = CURRENT_TIMESTAMP
 WHERE saga_id = 'saga-1042' AND version = 3 AND status = 'RUNNING';

Generic SQL for the outbox, processed-message and saga-state tables, plus a payment handler that dedupes by message ID, respects a cancellation tombstone and writes its reply to the outbox in the same local transaction.

Designing for missing isolation

A saga gives you atomicity (every step completes or is compensated), consistency, and durability. It does not give you isolation. Each local transaction commits as soon as it finishes, so other requests can see the order, the reserved stock, or the debited balance before the saga has decided whether they stand.

The anomalies

  • Lost updates: one saga overwrites a change made by another without reading it first. For example, a compensation restores a stock count that a second saga has since decremented.
  • Dirty reads: a reader acts on intermediate state that is later compensated. For example, a credit check approves spending against funds that a failing saga is about to release.
  • Fuzzy (non-repeatable) reads: two steps of the same saga read the same record and get different values, because another saga committed in between.

Countermeasures

  • Semantic locks: mark records with a status such as PENDING. Other sagas then reject, wait on, or route around those records instead of treating them as final.
  • Commutative updates: express changes as increments and decrements rather than absolute writes, so they apply correctly in any order.
  • Pessimistic ordering: put risky steps that are hard to compensate after the steps that can fail. That way, nothing is exposed until the outcome is nearly certain.
  • Re-reading values: check that a record is still unchanged before overwriting it.
  • Version checks: reject a write if the record's version has moved since it was read.

The cost spreads

Pending state is visible far beyond the saga. Queries must decide whether to include PENDING rows. APIs need a way to return "accepted, not confirmed." UIs have to show provisional results and handle reversals. How long that state stays visible depends on broker latency, retry policy, and step timeouts. Every consumer of your data must handle that window.

-- PostgreSQL. Stock reservation step of an order saga, plus its compensation.
CREATE TABLE inventory (
  sku       text PRIMARY KEY,
  available integer NOT NULL CHECK (available >= 0)
);
CREATE TABLE reservations (
  id      uuid PRIMARY KEY,
  saga_id uuid NOT NULL,
  sku     text NOT NULL REFERENCES inventory (sku),
  qty     integer NOT NULL,
  status  text NOT NULL CHECK (status IN ('PENDING', 'CONFIRMED', 'CANCELLED')),
  version integer NOT NULL DEFAULT 0
);

-- Forward step: a relative decrement (commutative) instead of writing a value read earlier.
-- The guard makes the step fail rather than oversell. Zero rows updated means fail the saga.
UPDATE inventory SET available = available - 3
 WHERE sku = 'SKU-1042' AND available >= 3;
-- Semantic lock: the reservation exists but is visibly not final.
INSERT INTO reservations (id, saga_id, sku, qty, status)
VALUES ('8f14e45f-ceea-4e7a-9b1d-000000000001', '3c59dc04-8a1b-4f2e-9c3d-000000000002',
        'SKU-1042', 3, 'PENDING');

-- Compensation: release only a reservation that is still PENDING at the version we read,
-- then add the quantity back as an increment, never as a restore of the old count.
WITH cancelled AS (
  UPDATE reservations
     SET status = 'CANCELLED', version = version + 1
   WHERE id = '8f14e45f-ceea-4e7a-9b1d-000000000001'
     AND status = 'PENDING' AND version = 0   -- version check: 0 rows means re-read first
  RETURNING sku, qty
)
UPDATE inventory i SET available = i.available + c.qty
  FROM cancelled c WHERE i.sku = c.sku;

-- Readers outside the saga must choose explicitly whether provisional rows count.
SELECT sku, sum(qty) AS confirmed_qty FROM reservations
 WHERE status = 'CONFIRMED' GROUP BY sku;

The example applies a semantic PENDING lock, commutative increments and decrements, and a version-checked compensation to a saga's stock reservation step. Because the saga provides no isolation, the final query shows that every reader outside it must decide whether to count pending rows.

Running sagas in production

Production sagas fail in the gaps between steps, so the operational basics come first. Persist saga state durably after every transition so a restarted coordinator resumes rather than forgets. Give each step a deadline: a payment call that never answers must eventually count as a failure, or the saga waits forever. Propagate a saga ID through every log line, trace span and message header. Without it, reconstructing one order's path across five services means grepping by timestamp and guessing.

Stuck sagas and failed compensations

A compensation cannot be allowed to give up, because a half-compensated saga leaves the system inconsistent. Retry compensations until they succeed, using exponential backoff with jitter so a recovering service isn't hammered. Some compensations are impossible, such as refunding a closed card. Route those to a dead-letter queue, alert on queue depth and saga age, and keep a written runbook that tells the on-call engineer how to inspect the saga, decide on the fix and close it out by hand.

Irreversible side effects

You can't unsend an email or recall a request to a shipping partner. Schedule these after the pivot, the point past which the saga only moves forward, so a later failure never forces you to take them back.

Testing

Inject a failure at every step and at every compensation. Deliver duplicate and out-of-order messages. Kill the coordinator partway through a rollback. Each saga multiplies this test matrix, which is a real cost of adopting it.

When to use it, when to avoid it, and what it costs

That cost is the right lens for deciding whether a saga belongs in the design at all.

Sagas fit long-running business processes that cross services, where each service owns its data and the business accepts temporary inconsistency, such as a refund that settles minutes later. Avoid them when an operation needs strict isolation, when the flow rarely leaves one service, or when the team lacks reliable messaging. Without that messaging layer, compensations become guesswork. The microservice architecture forces say it plainly: prefer ACID over BASE when you can.

Option Fits when Main cost
Merge entities into one service The invariant belongs to one domain Larger service
Two-phase commit Every participant supports it Participants block when the coordinator fails
Shared database Teams can accept schema coupling Loss of team autonomy
Redesign the process The invariant can be relaxed or reordered Business negotiation
Saga None of the above apply Code, infrastructure, on-call load

Illustrative estimate (assumed numbers, not a benchmark). Assume N = 4 steps across 4 services, each needing an outbox table, a relay and an idempotency store, plus one saga state table and one orchestrator.

  • Components = 3N + 2 = 14
  • Failure paths = forward failures N + compensation failures (N − 1) = 7
  • Duplicate-delivery cases double that to 14 test scenarios.

To save engineering time:

  • Build the outbox, idempotency and correlation-ID handling once as shared infrastructure, so each new saga adds only one row to this count, not three per service.
  • Keep sagas short, because N drives every term in the formulas.
  • Draw service boundaries so the invariant stays inside one service. The cheapest saga is the one you don't need.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to saga pattern, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • The dependency graph answers "what does this touch?" in seconds instead of a manual audit.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • You can size a change's blast radius before merging, avoiding surprise cross-team firefighting.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.