โ† Back to DevBytes

Step Functions: Complete Setup and Configuration Guide

Introduction to AWS Step Functions

AWS Step Functions is a serverless orchestration service that lets you coordinate multiple AWS services into flexible, visual workflows. Instead of writing complex glue code to chain Lambda functions, SQS queues, DynamoDB tables, and SNS topics together, you define a state machine using the Amazon States Language (ASL) and let Step Functions handle retries, error handling, branching, and parallel execution for you.

Whether you are building an ETL pipeline, an order processing system, or a multi-step ML training workflow, Step Functions provides a reliable backbone that is observable, auditable, and scalable by default.

Why Step Functions Matter

Building distributed workflows by hand typically leads to a tangle of Lambda functions invoking other Lambda functions, custom retry logic, and brittle error handling. Step Functions solves several real engineering problems:

Key Concepts

State Machines

A state machine is the workflow definition. It is written in JSON using the Amazon States Language and contains a set of states plus transitions between them. Each state has a Type that determines its behavior.

States

The most common state types include:

Standard vs Express Workflows

Standard Workflows support long-running executions (up to one year), exactly-once processing, and full execution history. Express Workflows are designed for high-volume, short-duration workloads (up to five minutes) and come in two flavors: ASYNC (at-least-once) and SYNC (response returned to caller). Choose Express when you need millions of executions per second at a fraction of the cost.

Prerequisites

Before you begin, ensure you have the following:

Step 1: Create the IAM Execution Role

Every state machine needs an execution role that grants Step Functions permission to invoke the resources it orchestrates. Create a trust policy that allows the Step Functions service to assume the role:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "states.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

Save this as trust-policy.json and create the role with the AWS CLI:

aws iam create-role \
  --role-name StepFunctionsExecutionRole \
  --assume-role-policy-document file://trust-policy.json

Now attach a permissions policy that allows invoking Lambda functions and writing logs. For production, scope this tightly to the specific resources your workflow uses:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "lambda:InvokeFunction"
      ],
      "Resource": "arn:aws:lambda:*:*:function:*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogDelivery",
        "logs:GetLogDelivery",
        "logs:UpdateLogDelivery",
        "logs:DeleteLogDelivery",
        "logs:ListLogDeliveries",
        "logs:PutResourcePolicy",
        "logs:DescribeResourcePolicies",
        "logs:DescribeLogGroups"
      ],
      "Resource": "*"
    }
  ]
}

Attach the policy to the role:

aws iam put-role-policy \
  --role-name StepFunctionsExecutionRole \
  --policy-name StepFunctionsPermissions \
  --policy-document file://permissions-policy.json

Step 2: Create Lambda Functions

To keep the tutorial practical, create two simple Lambda functions: one that validates an order and one that charges a payment. Create a deployment package for the first function:

// validate-order.js
exports.handler = async (event) => {
  const { orderId, amount, customerId } = event;

  if (!orderId || !amount || !customerId) {
    throw new Error("Missing required fields");
  }

  if (amount <= 0) {
    throw new Error("Amount must be greater than zero");
  }

  return {
    orderId,
    amount,
    customerId,
    validatedAt: new Date().toISOString(),
    status: "VALID"
  };
};

Zip and create the function:

zip validate-order.zip validate-order.js

aws lambda create-function \
  --function-name ValidateOrder \
  --runtime nodejs18.x \
  --role arn:aws:iam::123456789012:role/LambdaExecutionRole \
  --handler validate-order.handler \
  --zip-file fileb://validate-order.zip

Create the second function that simulates a payment charge:

// charge-payment.js
exports.handler = async (event) => {
  const { orderId, amount } = event;

  // Simulate payment processing
  const success = Math.random() > 0.2;

  if (!success) {
    throw new Error("Payment declined by processor");
  }

  return {
    orderId,
    amount,
    chargedAt: new Date().toISOString(),
    transactionId: `txn_${Date.now()}`,
    status: "CHARGED"
  };
};

Deploy it the same way:

zip charge-payment.zip charge-payment.js

aws lambda create-function \
  --function-name ChargePayment \
  --runtime nodejs18.x \
  --role arn:aws:iam::123456789012:role/LambdaExecutionRole \
  --handler charge-payment.handler \
  --zip-file fileb://charge-payment.zip

Step 3: Define the State Machine

Now create the workflow definition in Amazon States Language. This example validates an order, charges payment, and handles failures with retries and a fallback notification state:

{
  "Comment": "Order processing workflow",
  "StartAt": "ValidateOrder",
  "States": {
    "ValidateOrder": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:ValidateOrder",
      "Retry": [
        {
          "ErrorEquals": ["States.TaskFailed"],
          "IntervalSeconds": 2,
          "MaxAttempts": 3,
          "BackoffRate": 2.0
        }
      ],
      "Catch": [
        {
          "ErrorEquals": ["States.ALL"],
          "Next": "ValidationFailed",
          "ResultPath": "$.error"
        }
      ],
      "Next": "ChargePayment"
    },
    "ChargePayment": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:ChargePayment",
      "Retry": [
        {
          "ErrorEquals": ["States.TaskFailed"],
          "IntervalSeconds": 3,
          "MaxAttempts": 2,
          "BackoffRate": 2.0
        }
      ],
      "Catch": [
        {
          "ErrorEquals": ["States.ALL"],
          "Next": "PaymentFailed",
          "ResultPath": "$.error"
        }
      ],
      "Next": "OrderComplete"
    },
    "OrderComplete": {
      "Type": "Succeed"
    },
    "ValidationFailed": {
      "Type": "Fail",
      "Error": "ValidationError",
      "Cause": "Order validation failed"
    },
    "PaymentFailed": {
      "Type": "Fail",
      "Error": "PaymentError",
      "Cause": "Payment could not be processed"
    }
  }
}

Save this as order-workflow.json. Be sure to replace the account ID and region with your own values.

Step 4: Create the State Machine

Use the AWS CLI to create the state machine, referencing the execution role you created earlier:

aws stepfunctions create-state-machine \
  --name OrderProcessingWorkflow \
  --definition file://order-workflow.json \
  --role-arn arn:aws:iam::123456789012:role/StepFunctionsExecutionRole \
  --type STANDARD

The response includes the state machine ARN. Save it for the next step. For high-throughput, short-lived workflows, replace --type STANDARD with --type EXPRESS.

Step 5: Start an Execution

Trigger the workflow with an input payload:

aws stepfunctions start-execution \
  --state-machine-arn arn:aws:states:us-east-1:123456789012:stateMachine:OrderProcessingWorkflow \
  --name "order-$(date +%s)" \
  --input '{"orderId":"ord-001","amount":49.99,"customerId":"cust-123"}'

The response returns an executionArn. Use it to check the status:

aws stepfunctions describe-execution \
  --execution-arn arn:aws:states:us-east-1:123456789012:execution:OrderProcessingWorkflow:order-1690000000

To inspect the detailed history of every state transition, input, output, and error, use:

aws stepfunctions get-execution-history \
  --execution-arn arn:aws:states:us-east-1:123456789012:execution:OrderProcessingWorkflow:order-1690000000

Step 6: Using the CDK for Infrastructure as Code

Managing Step Functions definitions as raw JSON becomes unwieldy as workflows grow. The AWS CDK lets you define state machines in TypeScript with full type safety. Install the required packages:

npm install @aws-cdk/aws-stepfunctions @aws-cdk/aws-stepfunctions-tasks @aws-cdk/aws-lambda

Here is an equivalent CDK definition of the order workflow:

import * as cdk from 'aws-cdk-lib';
import * as sfn from 'aws-cdk-lib/aws-stepfunctions';
import * as tasks from 'aws-cdk-lib/aws-stepfunctions-tasks';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';

export class OrderStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const validateFn = new lambda.Function(this, 'ValidateOrder', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'validate-order.handler',
      code: lambda.Code.fromAsset('lambda'),
    });

    const chargeFn = new lambda.Function(this, 'ChargePayment', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'charge-payment.handler',
      code: lambda.Code.fromAsset('lambda'),
    });

    const validateTask = new tasks.LambdaInvoke(this, 'ValidateOrder', {
      lambdaFunction: validateFn,
      retryOnServiceExceptions: true,
    });

    validateTask.addRetry({
      errors: ['States.TaskFailed'],
      interval: cdk.Duration.seconds(2),
      maxAttempts: 3,
      backoffRate: 2,
    });

    const chargeTask = new tasks.LambdaInvoke(this, 'ChargePayment', {
      lambdaFunction: chargeFn,
    });

    chargeTask.addRetry({
      errors: ['States.TaskFailed'],
      interval: cdk.Duration.seconds(3),
      maxAttempts: 2,
      backoffRate: 2,
    });

    const validationFailed = new sfn.Fail(this, 'ValidationFailed', {
      error: 'ValidationError',
      cause: 'Order validation failed',
    });

    const paymentFailed = new sfn.Fail(this, 'PaymentFailed', {
      error: 'PaymentError',
      cause: 'Payment could not be processed',
    });

    const success = new sfn.Succeed(this, 'OrderComplete');

    validateTask.addCatch(validationFailed, { resultPath: '$.error' });
    chargeTask.addCatch(paymentFailed, { resultPath: '$.error' });

    const definition = validateTask.next(chargeTask).next(success);

    new sfn.StateMachine(this, 'OrderProcessingWorkflow', {
      definition,
      stateMachineType: sfn.StateMachineType.STANDARD,
      timeout: cdk.Duration.minutes(15),
    });
  }
}

Deploy with cdk deploy and the CDK handles IAM roles, permissions, and resource creation automatically.

Advanced Patterns

Using the Map State for Batch Processing

The Map state iterates over an array and runs the same workflow for each item. This is ideal for batch processing, fan-out scenarios, and parallel data transformation:

{
  "StartAt": "ProcessItems",
  "States": {
    "ProcessItems": {
      "Type": "Map",
      "ItemsPath": "$.items",
      "MaxConcurrency": 10,
      "Iterator": {
        "StartAt": "ProcessSingleItem",
        "States": {
          "ProcessSingleItem": {
            "Type": "Task",
            "Resource": "arn:aws:lambda:us-east-1:123456789012:function:ProcessItem",
            "End": true
          }
        }
      },
      "ResultPath": "$.results",
      "End": true
    }
  }
}

For large datasets, use Distributed Map mode, which can read from S3, DynamoDB, or CSV files and process up to 10,000 child executions in parallel.

Choice State for Conditional Branching

The Choice state routes execution based on input values, similar to a switch statement:

{
  "Type": "Choice",
  "Choices": [
    {
      "Variable": "$.amount",
      "NumericGreaterThan": 1000,
      "Next": "ManualReview"
    },
    {
      "Variable": "$.amount",
      "NumericGreaterThan": 0,
      "Next": "AutoApprove"
    }
  ],
  "Default": "RejectOrder"
}

Parallel State for Concurrent Execution

When independent tasks can run simultaneously, the Parallel state reduces total execution time:

{
  "Type": "Parallel",
  "Branches": [
    {
      "StartAt": "SendEmail",
      "States": {
        "SendEmail": {
          "Type": "Task",
          "Resource": "arn:aws:lambda:us-east-1:123456789012:function:SendEmail",
          "End": true
        }
      }
    },
    {
      "StartAt": "UpdateInventory",
      "States": {
        "UpdateInventory": {
          "Type": "Task",
          "Resource": "arn:aws:lambda:us-east-1:123456789012:function:UpdateInventory",
          "End": true
        }
      }
    }
  ],
  "Next": "Complete"
}

Best Practices

Design for Idempotency

Step Functions may retry tasks after transient failures. Every Lambda function and service integration in your workflow must be safe to call multiple times with the same input. Use deterministic identifiers and check for existing state before performing side effects.

Use Service Integrations Instead of Wrapper Lambdas

Step Functions can call DynamoDB, SQS, SNS, ECS, Sagemaker, and many other services directly through optimized or SDK integrations. This reduces cold starts, lowers cost, and removes unnecessary code. For example, instead of a Lambda function that puts an item in DynamoDB, use a direct DynamoDB service integration in the Task resource:

{
  "Type": "Task",
  "Resource": "arn:aws:states:::dynamodb:putItem",
  "Parameters": {
    "TableName": "OrdersTable",
    "Item": {
      "orderId": { "S.$": "$.orderId" },
      "status": { "S": "PROCESSED" }
    }
  },
  "End": true
}

Keep Payloads Small

Step Functions has a 256 KB payload limit for input, output, and state transitions. Pass references (such as S3 object keys or DynamoDB keys) between states rather than large data blobs. Fetch the actual data inside the downstream task.

Implement Meaningful Retries

Not all errors should be retried. Distinguish between transient errors (network timeouts, throttling) and permanent errors (validation failures, business rule violations). Use specific error names in the ErrorEquals field and set MaxAttempts thoughtfully. Exponential backoff with jitter prevents retry storms.

Use ResultPath and InputPath Carefully

By default, a task's output replaces its input. Use ResultPath to merge output into a specific node, InputPath to filter input before processing, and OutputPath to select which portion of the state passes to the next step. This keeps your state data clean and predictable.

Enable Logging and Tracing

For Express Workflows, enable CloudWatch Logs to capture execution details. For both types, enable X-Ray tracing to visualize latency across service calls. This is critical for debugging distributed workflows in production:

aws stepfunctions create-state-machine \
  --name OrderProcessingWorkflow \
  --definition file://order-workflow.json \
  --role-arn arn:aws:iam::123456789012:role/StepFunctionsExecutionRole \
  --logging-configuration level=ALL,includeExecutionData=true \
  --tracing-configuration enabled=true

Version and Alias Your State Machines

When you update a state machine definition, in-flight executions continue using the old definition while new executions use the new one. For controlled rollouts, consider maintaining separate state machines for different versions and routing traffic through an API Gateway or event router.

Monitoring and Observability

Step Functions emits CloudWatch metrics automatically. Key metrics to monitor include:

Create CloudWatch Alarms on failure rates and set up EventBridge rules to trigger notifications when executions fail. For example, this EventBridge pattern matches any failed execution:

{
  "source": ["aws.states"],
  "detail-type": ["Step Functions Execution Status Change"],
  "detail": {
    "status": ["FAILED", "TIMED_OUT", "ABORTED"]
  }
}

Route this event to an SNS topic or a Lambda function that alerts your team with the execution ARN and failure details.

Cost Considerations

Standard Workflows are billed per state transition. A state transition occurs each time a workflow moves from one state to another, including retries. To control costs, consolidate related logic into fewer states where it makes sense, and use Express Workflows for high-volume, short-duration tasks where you are billed per execution and per GB-second of compute rather than per transition.

Review your workflow definitions regularly to eliminate unnecessary Pass and Wait states that add transitions without business value, and prefer direct service integrations over Lambda invocations to reduce both cost and latency.

Conclusion

AWS Step Functions transforms the way you build distributed workflows by replacing fragile, hand-rolled orchestration code with a declarative, visual, and durable state machine model. By defining your workflows in Amazon States Language or through the CDK, you gain built-in retry logic, error handling, branching, and parallel execution without writing the boilerplate that typically dominates integration code. Start with a simple two-state workflow, instrument it with CloudWatch and X-Ray, and iterate toward more complex patterns like Map, Parallel, and Distributed Map as your use cases demand. With thoughtful attention to idempotency, payload size, IAM scoping, and retry strategy, Step Functions becomes a reliable foundation for orchestrating even the most demanding serverless architectures.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles