Saga Pattern in Python for Distributed Transactions

Two-phase commit gives you real ACID guarantees across multiple databases, and almost nobody uses it at scale for a simple reason: it requires every participant to hold locks and stay available for the entire duration of the transaction, coordinated by a single component whose failure blocks the whole operation. In a microservices architecture where “reserve inventory,” “charge payment,” and “create shipment” are three different services with three different databases, none of which want to hold a lock while waiting on the others, 2PC’s coordination cost is usually worse than the problem it solves.

The Saga pattern trades strict atomicity for something more workable: a sequence of local transactions, each of which commits independently, with an explicit compensating transaction defined for each step to undo it if a later step fails. Nothing is ever locked waiting on a remote party — you accept a temporary inconsistent state and guarantee you can always walk it back to consistent.

The core model: steps with paired compensations

Every saga step needs exactly two things: an action, and the compensation that undoes it. Model that explicitly instead of scattering rollback logic across exception handlers:

from dataclasses import dataclass
from typing import Callable, Any
from enum import Enum

class StepStatus(Enum):
    PENDING = "pending"
    COMPLETED = "completed"
    COMPENSATED = "compensated"
    FAILED = "failed"

@dataclass
class SagaStep:
    name: str
    action: Callable[[dict], Any]
    compensation: Callable[[dict], Any]
    status: StepStatus = StepStatus.PENDING
    result: Any = None

The dict context passed to both action and compensation is deliberate — a compensation often needs data the action produced (you can’t release a specific inventory reservation without knowing the reservation ID the action created), so state has to flow forward through the saga, not just errors flowing backward.

An order fulfillment saga, concretely

def reserve_inventory(context: dict) -> dict:
    reservation = inventory_service.reserve(context["order_id"], context["items"])
    return {"reservation_id": reservation.id}

def release_inventory(context: dict):
    inventory_service.release(context["reservation_id"])

def charge_payment(context: dict) -> dict:
    charge = payment_service.charge(context["customer_id"], context["total_amount"])
    return {"charge_id": charge.id}

def refund_payment(context: dict):
    payment_service.refund(context["charge_id"])

def create_shipment(context: dict) -> dict:
    shipment = shipping_service.create(context["order_id"], context["address"])
    return {"shipment_id": shipment.id}

def cancel_shipment(context: dict):
    shipping_service.cancel(context["shipment_id"])

ORDER_SAGA_STEPS = [
    SagaStep("reserve_inventory", reserve_inventory, release_inventory),
    SagaStep("charge_payment", charge_payment, refund_payment),
    SagaStep("create_shipment", create_shipment, cancel_shipment),
]

Step ordering here isn’t arbitrary — inventory is reserved before payment is charged, so a failed payment never leaves inventory over-committed for longer than necessary, and shipment creation happens last, after the two steps that can actually fail for business reasons (out of stock, declined card) have already succeeded.

The orchestrator: run forward, unwind backward on failure

Orchestration — a single component explicitly driving the saga forward and deciding when to compensate — is the more testable and debuggable choice over choreography (each service reacting to the previous one’s event for most teams, because the full flow lives in one place instead of being implicit across N services’ event handlers:

class SagaExecutionError(Exception):
    def __init__(self, failed_step: str, original_error: Exception):
        self.failed_step = failed_step
        self.original_error = original_error
        super().__init__(f"Saga failed at step '{failed_step}': {original_error}")

class SagaOrchestrator:
    def __init__(self, steps: list[SagaStep]):
        self.steps = steps

    def execute(self, initial_context: dict) -> dict:
        context = dict(initial_context)
        completed_steps: list[SagaStep] = []

        for step in self.steps:
            try:
                result = step.action(context)
                step.status = StepStatus.COMPLETED
                step.result = result
                if result:
                    context.update(result)
                completed_steps.append(step)
            except Exception as exc:
                step.status = StepStatus.FAILED
                self._compensate(completed_steps, context)
                raise SagaExecutionError(step.name, exc) from exc

        return context

    def _compensate(self, completed_steps: list[SagaStep], context: dict):
        # Unwind in reverse order — last completed step gets compensated first
        for step in reversed(completed_steps):
            try:
                step.compensation(context)
                step.status = StepStatus.COMPENSATED
            except Exception as comp_exc:
                # A failed compensation is a genuine incident — surface it loudly, don't swallow it
                emit_alert(f"Compensation failed for step '{step.name}': {comp_exc}", severity="critical")
                raise

The reverse-order unwind matters: if payment charging fails after inventory was reserved, you compensate inventory (the only completed step) — you never call a compensation for a step that never ran. And a failed compensation is treated as a different, more serious class of problem than a failed action — the saga’s whole safety guarantee depends on compensations succeeding, so a compensation failure needs a human, not a silent retry-and-move-on.

Compensations must be idempotent

A compensation might be retried — the orchestrator process could crash mid-unwind and resume, or a compensation call might time out without the caller knowing if it actually succeeded. If release_inventory isn’t idempotent, a retry could release inventory that a different order’s reservation now occupies:

def release_inventory(context: dict):
    # Idempotent: releasing an already-released reservation is a no-op, not an error
    inventory_service.release_if_reserved(context["reservation_id"])

def refund_payment(context: dict):
    # Idempotent: check for an existing refund against this charge before issuing a new one
    if not payment_service.has_refund(context["charge_id"]):
        payment_service.refund(context["charge_id"])

This is non-negotiable for production sagas — every compensation function should be safe to call twice with the same context and produce the same end state as calling it once.

Surviving a crash mid-saga: persist state, don’t just hold it in memory

The SagaOrchestrator above loses all state if the process crashes between steps — an order could be left with inventory reserved and no payment charged, forever, if nothing resumes it. A durable saga log fixes this:

class DurableSagaOrchestrator(SagaOrchestrator):
    def __init__(self, steps: list[SagaStep], saga_log):
        super().__init__(steps)
        self.saga_log = saga_log  # e.g. a Postgres table or Redis-backed log

    def execute(self, saga_id: str, initial_context: dict) -> dict:
        context = dict(initial_context)
        completed_steps: list[SagaStep] = []

        for step in self.steps:
            self.saga_log.record_step_started(saga_id, step.name, context)
            try:
                result = step.action(context)
                step.status = StepStatus.COMPLETED
                if result:
                    context.update(result)
                self.saga_log.record_step_completed(saga_id, step.name, result)
                completed_steps.append(step)
            except Exception as exc:
                self.saga_log.record_step_failed(saga_id, step.name, str(exc))
                self._compensate(completed_steps, context)
                raise SagaExecutionError(step.name, exc) from exc

        self.saga_log.record_saga_completed(saga_id)
        return context

A background recovery job then scans the saga log on startup (or periodically) for sagas stuck in a non-terminal state, and either resumes forward execution or runs compensation for whatever completed before the crash — the log is what makes “resume after a crash” possible instead of “manually investigate which orders are in a broken state.”

Testing: assert the exact compensation sequence, not just the failure

The valuable test for a saga isn’t “does it raise an exception when a step fails” — it’s “does it compensate the right completed steps in the right order”:

def test_payment_failure_compensates_only_inventory():
    calls = []
    steps = [
        SagaStep("reserve_inventory", lambda ctx: calls.append("reserve") or {"reservation_id": "r1"},
                  lambda ctx: calls.append("release")),
        SagaStep("charge_payment", lambda ctx: (_ for _ in ()).throw(PaymentDeclinedError()),
                  lambda ctx: calls.append("refund")),
        SagaStep("create_shipment", lambda ctx: calls.append("ship"),
                  lambda ctx: calls.append("cancel_ship")),
    ]
    orchestrator = SagaOrchestrator(steps)

    with pytest.raises(SagaExecutionError):
        orchestrator.execute({"order_id": "o1"})

    assert calls == ["reserve", "release"]  # shipment never ran, so it's never compensated

That last assertion — create_shipment never appears in calls at all, not even its compensation — is the behavior that actually matters. A test that only checks “an exception was raised” would pass even if the orchestrator incorrectly tried to compensate a step that never executed.

Related Reading

The takeaway

The Saga pattern doesn’t give you the atomicity 2PC promises — it gives you something more honest about how distributed systems actually fail: a guaranteed path back to a consistent state through explicit, idempotent compensations, persisted durably enough to survive a crash mid-flight. That’s a weaker guarantee on paper and a meaningfully more operable one in practice, which is why it’s the pattern behind order fulfillment at Amazon-scale systems rather than a distributed lock held across three services and a network partition waiting to happen.