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:
- Decoupled orchestration: Business logic lives in individual functions; the workflow logic lives in the state machine.
- Built-in error handling: Retries, catchers, and fallback states are declarative.
- Visual debugging: The AWS Console renders each execution graphically, showing exactly where and why a workflow failed.
- Durability: Executions can run for up to one year in Standard workflows, surviving service restarts and transient failures.
- Native integrations: Over 220 AWS services can be called directly without writing wrapper Lambda code.
- Cost efficiency: Express Workflows charge per execution and are ideal for high-throughput, short-lived workloads.
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:
Taskโ performs a single unit of work, such as invoking a Lambda function.Choiceโ adds branching logic based on input.Parallelโ runs multiple branches concurrently.Mapโ iterates over a collection, running the same steps for each item.Waitโ pauses for a fixed duration or until a timestamp.Passโ passes input to output, useful for transforming data.Succeed/Failโ terminal states.
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:
- An AWS account with appropriate IAM permissions.
- The AWS CLI installed and configured with credentials.
- Node.js 18+ (for the Lambda examples below).
- Basic familiarity with JSON and IAM policies.
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:
ExecutionsStarted,ExecutionsSucceeded,ExecutionsFailed,ExecutionsTimedOutโ track workflow health.ExecutionThrottledโ indicates you are hitting concurrency limits.ExecutionTimeโ helps identify slow workflows that may need optimization.
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.