AWS Builder Center

Saga Pattern with Lambda Durable Functions in Python

Build a multi-step order workflow with automatic rollback using AWS Lambda durable functions. Pure Python, no Step Functions needed.

Multi-step transactions are everywhere in distributed systems. Place an order, and the system needs to reserve inventory, charge a payment, and confirm the shipment. If payment fails after inventory is already reserved, how do you undo the reservation cleanly?
This is the Saga pattern — a sequence of steps where each step has a corresponding compensating action that runs if a later step fails. Traditionally, implementing sagas on AWS required Step Functions state machines with complex error handling, or custom orchestration code with external state stores.
With AWS Lambda durable functions (GA since December 2025), you can implement the Saga pattern in pure Python with automatic checkpointing, no external state machine, and zero compute charges during pauses.
In this article, you'll build a production-ready saga that processes orders through three steps and automatically rolls back on failure.

What You'll Build

An order processing saga with three forward steps and two compensating transactions:
1
2
3
4
5
6
Forward flow:
Reserve Inventory → Process Payment → Confirm Order → SUCCESS

Failure flow (payment declined):
Reserve Inventory ✓ → Process Payment ✗ →
Compensate: Release Inventory → FAILED (consistent state)
The architecture uses a single Lambda durable function orchestrating three DynamoDB tables:
  • Inventory Table — tracks available and reserved stock
  • Payments Table — tracks payment reservations (RESERVED → CAPTURED or REFUNDED)
  • Orders Table — tracks order state (CONFIRMED or FAILED)

Why Durable Functions for Sagas?

Before durable functions, implementing a saga on AWS typically required:
  • A Step Functions state machine with Catch/Retry blocks on every state
  • DynamoDB or S3 for intermediate state storage
  • Multiple Lambda functions (one per step)
  • Complex error propagation between states
With durable functions, the entire saga lives in one Python function. Each step is checkpointed automatically — if the function is interrupted between steps, it resumes from the last completed checkpoint without re-executing previous work.
The key primitives:
  • @durable_step — marks a function as a checkpointed operation
  • context.step() — executes a step and stores the result
  • Standard Python try/except — catches failures and triggers compensation

The Implementation

SAM Template (template.yaml)

The infrastructure is straightforward — one durable Lambda function and three DynamoDB tables:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Resources:
SagaOrchestratorFunction:
Type: AWS::Serverless::Function
Properties:
Handler: orchestrator.lambda_handler
Runtime: python3.13
CodeUri: src/
DurableConfig:
ExecutionTimeout: 3600
RetentionPeriodInDays: 3
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref OrdersTable
- DynamoDBCrudPolicy:
TableName: !Ref PaymentsTable
- DynamoDBCrudPolicy:
TableName: !Ref InventoryTable
The DurableConfig block is what makes this function durable. ExecutionTimeout defines how long the durable execution can span (up to 1 year), and RetentionPeriodInDays controls how long checkpoint data is kept.

Forward Steps

Each step is decorated with @durable_step, which tells the SDK to checkpoint the result:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from aws_durable_execution_sdk_python.context import DurableContext, StepContext, durable_step
from aws_durable_execution_sdk_python.execution import durable_execution

@durable_step
def reserve_inventory(step_context: StepContext, order_id: str, items: list) -> dict:
"""Step 1: Reserve inventory for each item."""
for item in items:
inventory_table.update_item(
Key={"item_id": item["item_id"]},
UpdateExpression="SET available = available - :qty, reserved = reserved + :qty",
ExpressionAttributeValues={":qty": item["quantity"]},
ConditionExpression="available >= :qty",
)
return {"reservation_id": str(uuid.uuid4()), "reserved_items": items}

@durable_step
def process_payment(step_context: StepContext, order_id: str, amount: str, customer_id: str) -> dict:
"""Step 2: Process payment."""
if Decimal(amount) > Decimal("10000"):
raise PaymentDeclinedError(f"Payment of {amount} declined: exceeds limit")

payments_table.put_item(Item={
"payment_id": str(uuid.uuid4()),
"order_id": order_id,
"amount": Decimal(amount),
"status": "RESERVED",
})
return {"payment_id": payment_id, "status": "RESERVED"}

@durable_step
def confirm_order(step_context: StepContext, order_id: str, reservation: dict, payment: dict) -> dict:
"""Step 3: Confirm the order."""
orders_table.put_item(Item={
"order_id": order_id,
"status": "CONFIRMED",
"reservation_id": reservation["reservation_id"],
"payment_id": payment["payment_id"],
})
return {"order_id": order_id, "status": "CONFIRMED"}

Compensating Transactions

Compensation runs in reverse order — undoing the most recently completed step first:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@durable_step
def compensate_payment(step_context: StepContext, payment: dict) -> dict:
"""Undo: Refund the payment."""
payments_table.update_item(
Key={"payment_id": payment["payment_id"]},
UpdateExpression="SET #s = :status",
ExpressionAttributeValues={":status": "REFUNDED"},
ExpressionAttributeNames={"#s": "status"},
)
return {"payment_id": payment["payment_id"], "status": "REFUNDED"}

@durable_step
def compensate_inventory(step_context: StepContext, reservation: dict) -> dict:
"""Undo: Release reserved inventory."""
for item in reservation["reserved_items"]:
inventory_table.update_item(
Key={"item_id": item["item_id"]},
UpdateExpression="SET available = available + :qty, reserved = reserved - :qty",
ExpressionAttributeValues={":qty": item["quantity"]},
)
return {"reservation_id": reservation["reservation_id"], "status": "RELEASED"}

The Orchestrator

The orchestrator ties everything together. It tracks which steps completed and compensates in reverse on failure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@durable_execution
def lambda_handler(event, context: DurableContext) -> dict:
order_id = event["order_id"]
completed_steps = []

try:
# Forward steps
reservation = context.step(reserve_inventory(order_id, event["items"]))
completed_steps.append(("inventory", reservation))

payment = context.step(process_payment(order_id, event["total_amount"], event["customer_id"]))
completed_steps.append(("payment", payment))

confirmation = context.step(confirm_order(order_id, reservation, payment))
return {"status": "SUCCESS", "order_id": order_id}

except Exception as err:
# Compensate in REVERSE order
for step_name, step_data in reversed(completed_steps):
if step_name == "payment":
context.step(compensate_payment(step_data))
elif step_name == "inventory":
context.step(compensate_inventory(step_data))

orders_table.put_item(Item={"order_id": order_id, "status": "FAILED", "error": str(err)})
return {"status": "FAILED", "order_id": order_id, "error": str(err)}
The key insight: completed_steps acts as the saga log. When an exception occurs, the code iterates through completed steps in reverse and runs the corresponding compensation. Each compensation is itself a context.step() call — so even compensation is checkpointed and won't re-execute if the function replays.

Deploying and Testing

Deploy with SAM:
1
2
sam build
sam deploy --guided
Seed inventory data:
1
2
aws dynamodb put-item --table-name lambda-durable-saga-inventory \
--item '{"item_id": {"S": "ITEM-LAPTOP"}, "available": {"N": "50"}, "reserved": {"N": "0"}}'

Test the Happy Path

1
2
3
4
5
aws lambda invoke \
--function-name "lambda-durable-saga-orchestrator:$LATEST" \
--invocation-type Event \
--payload '{"order_id": "ORD-001", "customer_id": "CUST-100", "items": [{"item_id": "ITEM-LAPTOP", "quantity": 2}], "total_amount": "999.99"}' \
/tmp/response.json
After 15 seconds, check the orders table — status: CONFIRMED. Inventory decremented from 50 to 48.

Test the Failure Path

Submit an order over $10,000 to trigger a payment decline:
1
2
3
4
5
aws lambda invoke \
--function-name "lambda-durable-saga-orchestrator:$LATEST" \
--invocation-type Event \
--payload '{"order_id": "ORD-FAIL", "customer_id": "CUST-200", "items": [{"item_id": "ITEM-LAPTOP", "quantity": 5}], "total_amount": "15000.00"}' \
/tmp/response.json
After 30 seconds: order status is FAILED, and inventory is restored to 50. The compensation ran automatically.

Key Lessons Learned

1. Durable functions must be invoked with a qualified ARN. You cannot invoke using the unqualified function name — use $LATEST, a published version, or an alias.
2. Use UnrecoverableInvocationError (or standard exceptions) for business failures. The SDK retries steps by default. If a failure is permanent (like "payment declined"), raising an unrecoverable error skips retries and propagates immediately to the orchestrator's except block.
3. Compensation steps should be idempotent. If the function replays after a compensation step completes, the checkpoint prevents re-execution — but designing compensations to be safe on re-run is still a best practice.
4. DynamoDB Decimal types matter. Python floats cause TypeError when writing to DynamoDB. Use Decimal or str for numeric values.

When to Use This Pattern

The saga pattern with durable functions is ideal when:
  • Multiple services/tables must be updated atomically
  • Operations cannot be wrapped in a single transaction
  • Compensating logic differs per step
  • The workflow spans seconds to minutes (or longer with context.wait())
For simple two-step transactions, DynamoDB Transactions may be simpler. For workflows requiring complex branching, parallel execution, and visual debugging, Step Functions remains the better choice.

Conclusion

Lambda durable functions bring saga orchestration directly into your function code. No external state machine, no extra infrastructure — just Python with automatic checkpointing. The compensating transaction pattern becomes straightforward: track what completed, catch failures, and undo in reverse.
The complete pattern deploys in under 2 minutes with sam deploy and demonstrates production-ready error handling that customers can adapt to their own multi-step workflows.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article