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.