# Saga Compensation Pattern Low-Level Design: Distributed Transaction Rollback, Compensating Actions, and State Machine

Source: https://www.techinterview.org/post/3233468856/lld-saga-compensation/
Updated: 2026-04-17 · techinterview.org

## Saga Compensation Pattern Low-Level Design

Distributed transactions across microservices cannot use two-phase commit at scale — it introduces tight coupling and liveness risks. The Saga pattern breaks a distributed transaction into a sequence of local transactions, each with a corresponding compensating transaction that undoes its effect if a later step fails. This guide covers the full low-level design: orchestration-based state machine, compensating transaction registry, partial failure handling, idempotent rollback, and timeout-triggered compensation.

## Orchestration vs Choreography

Orchestration places a central coordinator (the saga orchestrator) in charge of executing each step and triggering compensation. Choreography distributes the control via events — each service listens for events and emits its own. Orchestration is easier to reason about, easier to observe, and the right choice when you need explicit rollback semantics with a clear audit trail.

This design uses orchestration. The orchestrator is a persistent state machine stored in the database, driven by a polling or event-driven loop.

## Saga State Machine

The saga instance transitions through a well-defined set of states:


```
PENDING
  → STEP_1_STARTED → STEP_1_COMPLETED
  → STEP_2_STARTED → STEP_2_COMPLETED
  → ...
  → COMPLETED

On failure at step N:
  STEP_N_STARTED → STEP_N_FAILED
  → COMPENSATING
  → COMP_N-1_STARTED → COMP_N-1_COMPLETED
  → COMP_N-2_STARTED → COMP_N-2_COMPLETED
  → ...
  → COMPENSATED | COMPENSATION_FAILED
```


The state is persisted to the database after every transition. If the orchestrator crashes, it resumes from the last durable state on restart.

## Compensating Transaction Registry

Each saga type registers its steps and their compensations at startup. The registry maps `(saga_type, step_number)` to a pair of callables: `forward_fn` and `compensate_fn`. Both must be idempotent — they may be called more than once due to retries.

Example saga: Order placement across inventory, payment, and fulfillment services.

- Step 1: Reserve inventory → Compensate: Release inventory reservation

- Step 2: Charge payment → Compensate: Refund payment

- Step 3: Create fulfillment order → Compensate: Cancel fulfillment order

If step 3 fails, compensation runs: cancel fulfillment (step 3 comp, if partially applied), refund payment (step 2 comp), release reservation (step 1 comp) — in reverse order.

## SQL Schema


```
CREATE TABLE saga_instance (
    id              UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    saga_type       VARCHAR(128) NOT NULL,
    state           VARCHAR(64)  NOT NULL DEFAULT 'PENDING',
    current_step    INT          NOT NULL DEFAULT 0,
    payload         JSONB        NOT NULL DEFAULT '{}',
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_saga_state ON saga_instance (state, updated_at);

CREATE TABLE saga_step (
    id                  BIGSERIAL   PRIMARY KEY,
    saga_id             UUID        NOT NULL REFERENCES saga_instance(id),
    step_number         INT         NOT NULL,
    step_name           VARCHAR(128) NOT NULL,
    status              VARCHAR(32)  NOT NULL DEFAULT 'PENDING',
    forward_result      JSONB,
    compensation_status VARCHAR(32)  NOT NULL DEFAULT 'NOT_NEEDED',
    executed_at         TIMESTAMPTZ,
    CONSTRAINT uq_saga_step UNIQUE (saga_id, step_number)
);

CREATE TABLE saga_compensation (
    id              BIGSERIAL   PRIMARY KEY,
    saga_id         UUID        NOT NULL REFERENCES saga_instance(id),
    step_number     INT         NOT NULL,
    attempt         INT         NOT NULL DEFAULT 1,
    status          VARCHAR(32)  NOT NULL DEFAULT 'PENDING',
    error_message   TEXT,
    started_at      TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    completed_at    TIMESTAMPTZ
);

CREATE INDEX idx_saga_comp_saga ON saga_compensation (saga_id, step_number);
```


## Python: Orchestrator Implementation


```
import uuid
import json
import psycopg2
from datetime import datetime, timezone
from typing import Callable, Dict, List, Optional, Tuple

# Registry: saga_type -> list of (step_name, forward_fn, compensate_fn)
SAGA_REGISTRY: Dict[str, List[Tuple[str, Callable, Callable]]] = {}

def register_saga(saga_type: str, steps: List[Tuple[str, Callable, Callable]]):
    SAGA_REGISTRY[saga_type] = steps


def execute_saga(saga_type: str, payload: dict) -> str:
    """Create a new saga instance and begin execution. Returns saga_id."""
    saga_id = str(uuid.uuid4())
    with psycopg2.connect(dsn="postgresql://app:pass@db/appdb") as conn:
        with conn.cursor() as cur:
            cur.execute(
                """
                INSERT INTO saga_instance (id, saga_type, state, current_step, payload)
                VALUES (%s, %s, 'PENDING', 0, %s)
                """,
                (saga_id, saga_type, json.dumps(payload)),
            )
        conn.commit()
    advance_step(saga_id)
    return saga_id


def advance_step(saga_id: str):
    """Execute the next pending step of the saga."""
    with psycopg2.connect(dsn="postgresql://app:pass@db/appdb") as conn:
        with conn.cursor() as cur:
            cur.execute(
                "SELECT saga_type, state, current_step, payload FROM saga_instance WHERE id = %s FOR UPDATE",
                (saga_id,),
            )
            row = cur.fetchone()
            if not row:
                raise ValueError(f"Saga {saga_id} not found")
            saga_type, state, current_step, payload = row

        steps = SAGA_REGISTRY.get(saga_type, [])
        if current_step >= len(steps):
            # All steps completed
            with conn.cursor() as cur:
                cur.execute(
                    "UPDATE saga_instance SET state = 'COMPLETED', updated_at = NOW() WHERE id = %s",
                    (saga_id,),
                )
            conn.commit()
            return

        step_name, forward_fn, _ = steps[current_step]
        with conn.cursor() as cur:
            cur.execute(
                """
                INSERT INTO saga_step (saga_id, step_number, step_name, status, executed_at)
                VALUES (%s, %s, %s, 'STARTED', NOW())
                ON CONFLICT (saga_id, step_number) DO UPDATE SET status = 'STARTED', executed_at = NOW()
                """,
                (saga_id, current_step, step_name),
            )
            cur.execute(
                "UPDATE saga_instance SET state = %s, updated_at = NOW() WHERE id = %s",
                (f"STEP_{current_step}_STARTED", saga_id),
            )
        conn.commit()

        try:
            result = forward_fn(payload)
            with conn.cursor() as cur:
                cur.execute(
                    """
                    UPDATE saga_step SET status = 'COMPLETED', forward_result = %s
                    WHERE saga_id = %s AND step_number = %s
                    """,
                    (json.dumps(result), saga_id, current_step),
                )
                cur.execute(
                    """
                    UPDATE saga_instance SET state = %s, current_step = %s, updated_at = NOW()
                    WHERE id = %s
                    """,
                    (f"STEP_{current_step}_COMPLETED", current_step + 1, saga_id),
                )
            conn.commit()
            advance_step(saga_id)
        except Exception as exc:
            with conn.cursor() as cur:
                cur.execute(
                    "UPDATE saga_step SET status = 'FAILED' WHERE saga_id = %s AND step_number = %s",
                    (saga_id, current_step),
                )
                cur.execute(
                    "UPDATE saga_instance SET state = 'COMPENSATING', updated_at = NOW() WHERE id = %s",
                    (saga_id,),
                )
            conn.commit()
            compensate_saga(saga_id, from_step=current_step - 1)


def compensate_saga(saga_id: str, from_step: int):
    """Run compensating transactions in reverse order from from_step down to 0."""
    with psycopg2.connect(dsn="postgresql://app:pass@db/appdb") as conn:
        with conn.cursor() as cur:
            cur.execute(
                "SELECT saga_type, payload FROM saga_instance WHERE id = %s",
                (saga_id,),
            )
            row = cur.fetchone()
            if not row:
                return
            saga_type, payload = row

    steps = SAGA_REGISTRY.get(saga_type, [])
    for step_number in range(from_step, -1, -1):
        step_name, _, compensate_fn = steps[step_number]
        try:
            compensate_fn(payload)
            with psycopg2.connect(dsn="postgresql://app:pass@db/appdb") as conn:
                with conn.cursor() as cur:
                    cur.execute(
                        """
                        UPDATE saga_step SET compensation_status = 'COMPLETED'
                        WHERE saga_id = %s AND step_number = %s
                        """,
                        (saga_id, step_number),
                    )
                    cur.execute(
                        """
                        INSERT INTO saga_compensation (saga_id, step_number, status, completed_at)
                        VALUES (%s, %s, 'COMPLETED', NOW())
                        """,
                        (saga_id, step_number),
                    )
                conn.commit()
        except Exception as exc:
            with psycopg2.connect(dsn="postgresql://app:pass@db/appdb") as conn:
                with conn.cursor() as cur:
                    cur.execute(
                        """
                        UPDATE saga_instance SET state = 'COMPENSATION_FAILED', updated_at = NOW()
                        WHERE id = %s
                        """,
                        (saga_id,),
                    )
                    cur.execute(
                        """
                        INSERT INTO saga_compensation (saga_id, step_number, status, error_message)
                        VALUES (%s, %s, 'FAILED', %s)
                        """,
                        (saga_id, step_number, str(exc)),
                    )
                conn.commit()
            return

    with psycopg2.connect(dsn="postgresql://app:pass@db/appdb") as conn:
        with conn.cursor() as cur:
            cur.execute(
                "UPDATE saga_instance SET state = 'COMPENSATED', updated_at = NOW() WHERE id = %s",
                (saga_id,),
            )
        conn.commit()
```


## Timeout Handling

A step that does not complete within its deadline must trigger compensation. A background job polls `saga_instance` for rows in a `STEP_N_STARTED` state with `updated_at` older than the step timeout. On detection, it transitions the saga to `COMPENSATING` and calls `compensate_saga(saga_id, from_step=N-1)`. The timed-out step itself is not compensated (it never completed), but all prior completed steps are.

See also: [Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems](https://www.techinterview.org/post/3233460268/stripe-interview-guide-2026-process-bug-bash-round-and-payment-systems/)

See also: [Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering](https://www.techinterview.org/post/3233460275/scale-ai-interview-guide-2026-data-infrastructure-rlhf-pipelines-and-ml-engineering/)

See also: [Meta Interview Guide 2026: Facebook, Instagram, WhatsApp Engineering](https://www.techinterview.org/post/3233460272/meta-interview-guide-2026-facebook-instagram-whatsapp-engineering/)
