Step Functions

Coordinate distributed workloads using visual state machines. Step Functions turns complex, multi-service pipelines into declarative workflows with built-in retry, error handling, and audit logging.

Introduction

In Lesson 9 we compared Lambda Durable Functions to Step Functions and recommended Step Functions when your workflow spans multiple AWS services or needs visual monitoring. This lesson makes that concrete: you will learn the state machine model, understand when Step Functions pays off versus simpler alternatives, and deploy a real order-processing pipeline that combines Task, Choice, Parallel, Retry, and Fail states in one workflow.

1. What Are Step Functions?

AWS Step Functions is a serverless orchestration service. You define a state machine in JSON (Amazon States Language, ASL), and Step Functions executes each state in sequence, routes between them based on outcomes, handles retries, and records every transition. A visual execution graph is generated automatically in the console.

Standard Workflow
  • Exactly-once execution - each state runs once per execution
  • Duration: up to 1 year
  • Full audit trail - every state transition stored in execution history
  • Throughput: up to 2,000 new executions per second
  • Cost: $0.025 per 1,000 state transitions
  • Best for: order workflows, document approvals, multi-step ETL pipelines
Express Workflow
  • At-least-once execution - idempotent steps required
  • Duration: up to 5 minutes
  • No built-in audit trail - log to CloudWatch manually
  • Throughput: 100,000+ new executions per second
  • Cost: $1 per 1M executions + $0.00001 per GB-second
  • Best for: high-volume IoT ingestion, streaming event processing

State Types

Task

Calls a service: Lambda, DynamoDB, SNS, ECS, and 220+ others. Supports per-state Retry and Catch.

Choice

Branches on input data values. Each rule evaluates a JSONPath condition; the first match wins.

Parallel

Runs multiple branches simultaneously. Waits for all branches to complete before proceeding.

Map

Iterates over an array, running a sub-workflow for each item with configurable concurrency.

Wait

Pauses for a fixed duration, until a timestamp, or indefinitely until a callback token is returned.

Pass

Passes input to output, optionally injecting fixed data. Used for data shaping and testing.

Succeed

Terminates the execution successfully. The output is the current state's input.

Fail

Terminates the execution with an error code and cause. Triggers Catch handlers in Task states.

Step Functions as an Orchestrator

EVBEventBridgeRule / API callSFStep FunctionsState MachineλLambdaValidate / PayDDBDynamoDBInventorySNSSNSNotificationsstartTask (invoke)Task (native)Task (native)

Step Functions calls Lambda for custom logic and integrates natively with DynamoDB and SNS, no Lambda glue functions needed

2. Pros and Cons

Pros
  • Native integrations - call DynamoDB, SNS, SQS, ECS, Bedrock, and 220+ services directly; no Lambda glue code needed
  • Per-state error handling - define Retry and Catch in ASL; no try/except scattered across multiple Lambda functions
  • Visual monitoring - the console shows a live execution graph; pinpoint exactly which state failed and why
  • Long-running workflows - Standard executions run up to 1 year; ideal for document review, multi-day ETL, human approval flows
  • Wait for callback - pause a workflow indefinitely until an external system sends a task token back (human approval, third-party webhook)
  • Parallel and Map states - fan-out across multiple branches or array items with configurable concurrency; merge results automatically
Cons
  • ASL verbosity - state machine JSON is verbose; a 5-state workflow can easily exceed 100 lines of configuration
  • Standard workflow cost - $0.025 per 1,000 transitions adds up for high-frequency workflows; use Express for high throughput
  • 25,000 event limit - Standard executions cap at 25,000 history events; deeply nested Parallel or Map states can hit this
  • Debugging complexity - failures inside nested Parallel branches can be hard to trace without structured CloudWatch logging
  • Vendor lock-in - ASL is AWS-specific; migrating to another platform means rewriting all workflow definitions
  • No native loops - ASL has no loop construct; loops require recursive executions or Map state workarounds

3. When to Use Step Functions

Step Functions adds orchestration overhead. It pays for itself when any of these are true: your workflow spans more than two AWS services, runs longer than 15 minutes, needs a human approval step, or requires a visual audit trail.

ConcernStep FunctionsLambda Durable FunctionsSQS + Lambda
Workflow styleVisual state machine (ASL JSON)Python code with decoratorsEvent-driven message passing
Max duration1 year (Standard)15 minutes (Lambda limit)Unlimited (messages requeue)
Error handlingPer-state Retry and Catch in ASLPython exceptions + retry configVisibility timeout + DLQ
Service integrations220+ native, no Lambda neededLambda-to-Lambda via SDKSQS polling only
ObservabilityVisual execution graph + full audit logCloudWatch logsCloudWatch queue metrics
Best forMulti-service orchestration, parallel fan-out, human approvalTightly coupled Python workflows, prefer code over DSLSimple async decoupling, high-throughput queuing

Rule of thumb: if your pipeline calls more than 2 AWS services, runs longer than 15 minutes, or needs a human approval step, Step Functions is the right tool. For tightly coupled Python logic under the Lambda timeout, Lambda Durable Functions keeps everything in code. For simple async decoupling between two services, SQS is simpler and cheaper.

4. Example: Order Processing Workflow

This pipeline validates an order, processes payment with automatic retry, then routes based on the result: small orders auto-approve and fan out to DynamoDB and SNS in parallel; large orders pause at a manual review step using a task token and only continue once a reviewer approves or rejects them. It demonstrates six state types in one production-like workflow.

Order Processing State Machine

StartValidateOrderTask: LambdaIsValidProcessPaymentTask: Lambda + RetryPaymentStatusSendForManualReviewTask: SQS + TokenReviewDecisionOrderRejectedFailUpdateInventoryTask: DynamoDBSendConfirmationTask: SNSOrderCompleteSucceedEndvalid == trueDefaultAPPROVEDDefaultapproved == trueDefault

Figure 1: Complete order workflow — valid orders auto-approve via PaymentStatus, large orders pause at SendForManualReview (Task Token) and resume on ReviewDecision

The console's Workflow Studio generates this JSON automatically. Understanding it helps when debugging failed executions.

{
  "Comment": "Order processing: Task, Choice, Parallel, Retry, Fail, Succeed, and Wait-for-Callback states",
  "StartAt": "ValidateOrder",
  "States": {
    "ValidateOrder": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:ValidateOrder",
      "ResultPath": "$.validation",
      "Retry": [
        {
          "ErrorEquals": ["Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException"],
          "IntervalSeconds": 2,
          "MaxAttempts": 6,
          "BackoffRate": 2
        }
      ],
      "Next": "IsValid"
    },
    "IsValid": {
      "Type": "Choice",
      "Choices": [
        { "Variable": "$.validation.valid", "BooleanEquals": true, "Next": "ProcessPayment" }
      ],
      "Default": "OrderRejected"
    },
    "ProcessPayment": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:ProcessPayment",
      "ResultPath": "$.payment",
      "Retry": [
        {
          "ErrorEquals": ["Lambda.TooManyRequestsException"],
          "IntervalSeconds": 2,
          "MaxAttempts": 3,
          "BackoffRate": 2
        }
      ],
      "Next": "PaymentStatus"
    },
    "PaymentStatus": {
      "Type": "Choice",
      "Choices": [
        { "Variable": "$.payment.status", "StringEquals": "APPROVED", "Next": "FulfillOrder" }
      ],
      "Default": "SendForManualReview"
    },
    "SendForManualReview": {
      "Type": "Task",
      "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken",
      "Parameters": {
        "QueueUrl": "https://sqs.us-east-1.amazonaws.com/123456789012/ManualReviewQueue",
        "MessageBody": {
          "taskToken.$": "$$.Task.Token",
          "orderId.$": "$.orderId",
          "total.$": "$.total"
        }
      },
      "HeartbeatSeconds": 172800,
      "ResultPath": "$.review",
      "Next": "ReviewDecision"
    },
    "ReviewDecision": {
      "Type": "Choice",
      "Choices": [
        { "Variable": "$.review.approved", "BooleanEquals": true, "Next": "FulfillOrder" }
      ],
      "Default": "OrderRejected"
    },
    "FulfillOrder": {
      "Type": "Parallel",
      "Branches": [
        {
          "StartAt": "UpdateInventory",
          "States": {
            "UpdateInventory": {
              "Type": "Task",
              "Resource": "arn:aws:states:::dynamodb:updateItem",
              "Parameters": {
                "TableName": "InventoryTable",
                "Key": { "orderId": { "S.$": "$.orderId" } },
                "UpdateExpression": "SET #s = :v",
                "ExpressionAttributeNames": { "#s": "status" },
                "ExpressionAttributeValues": { ":v": { "S": "PROCESSING" } }
              },
              "ResultPath": null,
              "End": true
            }
          }
        },
        {
          "StartAt": "SendConfirmation",
          "States": {
            "SendConfirmation": {
              "Type": "Task",
              "Resource": "arn:aws:states:::sns:publish",
              "Parameters": {
                "TopicArn": "arn:aws:sns:us-east-1:123456789012:OrderNotifications",
                "Message.$": "$.orderId",
                "Subject": "Order Confirmed"
              },
              "ResultPath": null,
              "End": true
            }
          }
        }
      ],
      "Next": "OrderComplete"
    },
    "OrderRejected": {
      "Type": "Fail",
      "Error": "ValidationFailed",
      "Cause": "Order did not pass validation checks"
    },
    "OrderComplete": { "Type": "Succeed" }
  }
}

import aws_cdk as cdk
from aws_cdk import (
    aws_stepfunctions as sfn,
    aws_stepfunctions_tasks as tasks,
    aws_lambda as lambda_,
    aws_dynamodb as dynamodb,
    aws_sns as sns,
    aws_sqs as sqs,
)
from constructs import Construct

class OrderWorkflowStack(cdk.Stack):
    def __init__(self, scope: Construct, id: str, **kwargs):
        super().__init__(scope, id, **kwargs)

        table        = dynamodb.Table(self, "InventoryTable", ...)
        topic        = sns.Topic(self, "OrderNotifications")
        review_queue = sqs.Queue(self, "ManualReviewQueue",
            visibility_timeout=cdk.Duration.hours(1),
        )

        validate_fn = lambda_.Function(self, "ValidateFn", ...)
        payment_fn  = lambda_.Function(self, "PaymentFn", ...)
        approve_fn  = lambda_.Function(self, "ApproveFn", ...)

        validate = tasks.LambdaInvoke(self, "ValidateOrder",
            lambda_function=validate_fn,
            payload_response_only=True,   # unwraps Payload key so $.validation.valid works directly
            result_path="$.validation",
        )
        process_payment = tasks.LambdaInvoke(self, "ProcessPayment",
            lambda_function=payment_fn,
            payload_response_only=True,
            result_path="$.payment",
        )
        process_payment.add_retry(
            errors=["Lambda.TooManyRequestsException"],
            interval=cdk.Duration.seconds(2),
            max_attempts=3,
            backoff_rate=2,
        )
        update_inventory = tasks.DynamoUpdateItem(self, "UpdateInventory",
            table=table,
            key={"orderId": tasks.DynamoAttributeValue.from_string(
                sfn.JsonPath.string_at("$.orderId"))},
            update_expression="SET #s = :v",
            expression_attribute_names={"#s": "status"},
            expression_attribute_values={":v": tasks.DynamoAttributeValue.from_string("PROCESSING")},
            result_path=sfn.JsonPath.DISCARD,   # don't overwrite state input
        )
        send_confirmation = tasks.SnsPublish(self, "SendConfirmation",
            topic=topic,
            message=sfn.TaskInput.from_json_path_at("$.orderId"),
            subject="Order Confirmed",
            result_path=sfn.JsonPath.DISCARD,
        )

        # Sends task token to SQS — execution pauses until approve_fn calls back
        send_for_review = tasks.SqsSendMessage(self, "SendForManualReview",
            queue=review_queue,
            message_body=sfn.TaskInput.from_object({
                "taskToken": sfn.JsonPath.task_token,
                "orderId":   sfn.JsonPath.string_at("$.orderId"),
                "total":     sfn.JsonPath.string_at("$.total"),
            }),
            integration_pattern=sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN,
            heartbeat=cdk.Duration.hours(48),
            result_path="$.review",
        )

        order_rejected = sfn.Fail(self, "OrderRejected",
            error="ValidationFailed",
            cause="Order did not pass validation checks",
        )
        order_complete = sfn.Succeed(self, "OrderComplete")

        fulfill = sfn.Parallel(self, "FulfillOrder").branch(
            update_inventory,
        ).branch(
            send_confirmation,
        )

        # fulfill.next() must be called exactly once; the Chain is then reused
        # in both PaymentStatus and ReviewDecision without triggering a double-next error
        fulfillment = fulfill.next(order_complete)

        is_valid        = sfn.Choice(self, "IsValid")
        payment_status  = sfn.Choice(self, "PaymentStatus")
        review_decision = sfn.Choice(self, "ReviewDecision")

        review_decision.when(
            sfn.Condition.boolean_equals("$.review.approved", True), fulfillment,
        ).otherwise(order_rejected)

        payment_status.when(
            sfn.Condition.string_equals("$.payment.status", "APPROVED"), fulfillment,
        ).otherwise(send_for_review.next(review_decision))

        is_valid.when(
            sfn.Condition.boolean_equals("$.validation.valid", True),
            process_payment.next(payment_status),
        ).otherwise(order_rejected)

        state_machine = sfn.StateMachine(self, "OrderWorkflow",
            definition_body=sfn.DefinitionBody.from_chainable(validate.next(is_valid)),
            state_machine_type=sfn.StateMachineType.STANDARD,
            timeout=cdk.Duration.hours(1),
        )
        # approve_fn needs SendTaskSuccess / SendTaskFailure on this state machine
        state_machine.grant_task_response(approve_fn)

Execution names must be unique per state machine. Appending a short UUID suffix avoidsExecutionAlreadyExists errors when re-running the script. The Parallel state returns an array - one element per branch - so unwrap it before reading the output.

import boto3, json, time, uuid

sf = boto3.client("stepfunctions")
STATE_MACHINE_ARN = "arn:aws:states:us-east-1:123456789012:stateMachine:OrderWorkflow"

def start(order: dict) -> str:
    resp = sf.start_execution(
        stateMachineArn=STATE_MACHINE_ARN,
        name=f"order-{order['orderId']}-{uuid.uuid4().hex[:8]}",  # must be unique
        input=json.dumps(order),
    )
    return resp["executionArn"]

def poll(execution_arn: str, max_polls: int = 15) -> dict:
    terminal = {"SUCCEEDED", "FAILED", "TIMED_OUT", "ABORTED"}
    for _ in range(max_polls):
        result = sf.describe_execution(executionArn=execution_arn)
        status = result["status"]
        print(f"  status: {status}")
        if status in terminal:
            return result
        time.sleep(2)
    print("  execution is paused — waiting for manual review callback")
    return result

order = {
    "orderId": "abc-001", "customerId": "cust-789",
    "items": [{"sku": "PROD-001", "qty": 2}], "total": 49.99,
}
result = poll(start(order))

if result["status"] == "SUCCEEDED":
    output = json.loads(result["output"])
    if isinstance(output, list):    # Parallel state returns one element per branch
        output = output[0]
    print(output.get("payment"))    # {"transactionId": "...", "status": "APPROVED", ...}
elif result["status"] == "RUNNING":
    print("paused at manual review — run the approval script to resume")
else:
    print(result.get("cause"))

Orders paused at SendForManualReview drop a task token into SQS. Reading the token and invoking the approval Lambda resumes the execution immediately - no polling required.

import boto3, json

sqs = boto3.client("sqs")
lmb = boto3.client("lambda")

QUEUE_URL  = "https://sqs.us-east-1.amazonaws.com/123456789012/ManualReviewQueue"
APPROVE_FN = "OrderWorkflowStack-ApproveFn-xxx"   # from CDK output

# 1. Read the task token from the review queue
msg  = sqs.receive_message(QueueUrl=QUEUE_URL, WaitTimeSeconds=5)["Messages"][0]
body = json.loads(msg["Body"])
print(f"Order {body['orderId']}  total=${body['total']:,.2f}")

# 2. Invoke the approval Lambda — execution resumes immediately
lmb.invoke(
    FunctionName=APPROVE_FN,
    Payload=json.dumps({
        "taskToken": body["taskToken"],
        "approved":  True,       # False to reject
        "reviewer":  "alice",
    }).encode(),
)

# 3. Delete the message so it is not reprocessed
sqs.delete_message(QueueUrl=QUEUE_URL, ReceiptHandle=msg["ReceiptHandle"])

Bonus: Full Working Project

The complete, deployable version of the order processing workflow is available on GitLab. It includes all Lambda functions, the CDK stack, and the scripts shown in this lesson, ready to deploy with a single cdk deploy.

The repository includes:

  • lambdas/validate - validates orderId, customerId, items, and total
  • lambdas/payment - auto-approves orders under $10k, returns REQUIRES_REVIEW for larger ones
  • lambdas/approve - calls SendTaskSuccess or SendTaskFailure with the task token
  • stacks/order_workflow_stack.py - full CDK stack with SQS, SNS, DynamoDB, three Lambda functions, and six state machine states
  • scripts/run_execution.py - starts three sample executions covering all branches
  • scripts/approve_execution.py - reads the task token from SQS and triggers the approval Lambda

Key Takeaways

  • Standard vs Express - Standard for long-running, exactly-once workflows with audit history; Express for high-throughput sub-5-minute processing
  • Native integrations - Task states can call DynamoDB, SNS, SQS, ECS, Bedrock, and 220+ services directly; no Lambda glue function required
  • Per-state Retry and Catch - define retry intervals, backoff rates, and error handlers in ASL rather than try/except in every Lambda function
  • Parallel and Map states - fan-out across multiple services simultaneously, then merge results; essential for order fulfillment and document pipelines
  • Wait for callback - pause indefinitely using a task token; resume when an external system calls SendTaskSuccess - ideal for human approval steps
  • Step Functions vs Durable Functions - Step Functions for service-centric pipelines and visual audit trails; Durable Functions when workflow logic lives in Python code