Skip to content

Sagas: transactions that can't ROLLBACK

Book a flight with one provider, a hotel with another, charge a card with a third. Three services, three databases — and no transaction that spans them. A saga is the honest answer: a chain of local transactions, where every completed step has a prepared apology — a compensating transaction that semantically undoes it if a later step fails.

Watch a saga fail forward

The demo compresses the idea into one database so every claim stays assertable — the two tables play the two services. The mechanics are the point: step 1 commits for real, so when step 2 fails, the only way back is a new forward transaction:

Step 1 — book the flight. A local transaction, committed immediately.

Saga> BEGIN;
BEGIN

Saga> UPDATE flights SET seats = seats - 1 WHERE id = 1 RETURNING seats;
 seats 
-------
     4 
(1 row)

Saga> COMMIT;
COMMIT

A saga has no isolation: between steps, the whole world sees the half-done trip.

Reader> SELECT seats FROM flights WHERE id = 1;
 seats 
-------
     4 
(1 row)

Step 2 — book the hotel. No rooms left: the step fails as a business outcome, not an error.

Saga> BEGIN;
BEGIN

Saga> UPDATE hotels SET rooms = rooms - 1 WHERE id = 1 AND rooms > 0; -- UPDATE 0 — nothing to book
UPDATE 0

Saga> ROLLBACK;
ROLLBACK

Step 1 already committed — there is nothing a ROLLBACK could undo. The saga runs a COMPENSATING transaction: a new forward transaction that reverses the booking.

Saga> BEGIN;
BEGIN

Saga> UPDATE flights SET seats = seats + 1 WHERE id = 1 RETURNING seats;
 seats 
-------
     5 
(1 row)

Saga> COMMIT;
COMMIT

Verified against PostgreSQL 18.4 · Run it yourself · Scenario source

What the transcript just proved

Three things in that transcript are worth naming. Each step commits immediately, so there's no long-lived transaction holding locks or pinning VACUUM across service calls — which is the whole reason sagas exist. A saga also has no isolation: Reader saw the booked seat between steps, a state the saga later revoked, so every anomaly chapter 2 catalogued between statements can now happen between steps, and no isolation level can help, because there's no enclosing transaction to reach for. If another traveler grabs a seat based on what they saw mid-saga, that's yours to design for.

And compensation is not ROLLBACK. seats = seats + 1 is ordinary committed history — the anomaly window really happened and stays visible in the log. Compensations have to be written per step and have to tolerate being retried, so make them idempotent; some steps, an email sent or cash dispensed, have none at all, which is why you order the saga to put irreversible steps last.

Underneath it all, a saga trades one impossible distributed transaction for N possible local ones plus N compensations you write and test yourself. Isolation is gone between the steps, so name the intermediate states, decide who is allowed to see them, and push irreversible steps to the end. And because steps and their compensations travel between services as messages, the outbox is the saga's transport: each step's "done" event commits atomically with the step that produced it.

Further reading

MIT Licensed · Every transcript on this site was generated by a real database run against MySQL 8.4.10 and PostgreSQL 18.4 at bd6f201, and re-proven through psycopg and PyMySQL.