AWS Step Functions
Managed workflow orchestration. You define a state machine (JSON / Amazon States Language); Step Functions runs it, handles retries, branching, parallelism, and keeps the execution history. The AWS-native alternative to Celery canvas / Temporal / hand-rolled orchestration.
Express vs Standard workflows
The first decision, and a guaranteed interview question.
| Standard | Express | |
|---|---|---|
| Max duration | 1 year | 5 minutes |
| Execution semantics | exactly-once | at-least-once (sync) / at-most-once (async) |
| Execution history | full, in the console, 90 days | CloudWatch Logs only |
| Pricing | per state transition | per request + duration (much cheaper at high volume) |
| Use case | long-running, auditable business processes (order fulfilment, ETL pipelines, human approval) | high-volume short-lived workflows (event processing, stream backends) |
| Idempotency | handled by exactly-once | you must make steps idempotent — at-least-once means retries |
Rule of thumb: Standard for orchestration you need to see and audit; Express for high-throughput event processing where cost matters and you’ve made steps idempotent.
State types
A state machine is a graph of states. The main ones:
| State | Purpose |
|---|---|
Task |
do work — invoke Lambda, call an AWS SDK action, run an ECS task, etc. |
Choice |
branch based on input (if/else) |
Parallel |
run multiple branches concurrently, wait for all |
Map |
run the same steps over each item in an array |
Wait |
pause for a duration or until a timestamp |
Pass |
inject or transform data without doing work |
Succeed |
terminal — success |
Fail |
terminal — failure with an error name |
{
"Comment": "Order processing",
"StartAt": "ValidateOrder",
"States": {
"ValidateOrder": {
"Type": "Task",
"Resource": "arn:aws:lambda:...:function:validate-order",
"Retry": [{"ErrorEquals": ["States.TaskFailed"], "MaxAttempts": 3, "BackoffRate": 2.0}],
"Catch": [{"ErrorEquals": ["ValidationError"], "Next": "RejectOrder"}],
"Next": "ChargeCard"
},
"ChargeCard": {
"Type": "Task",
"Resource": "arn:aws:lambda:...:function:charge-card",
"Next": "FulfillOrder"
},
"FulfillOrder": { "Type": "Task", "Resource": "...", "End": true },
"RejectOrder": { "Type": "Fail", "Error": "OrderRejected" }
}
}
Error handling — Retry and Catch
The reason to use Step Functions over hand-rolled orchestration: retries and error routing are declarative.
"Retry": [
{
"ErrorEquals": ["States.Timeout", "Lambda.ServiceException"],
"IntervalSeconds": 2,
"MaxAttempts": 3,
"BackoffRate": 2.0
}
],
"Catch": [
{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.error",
"Next": "HandleFailure"
}
]
- Retry — retry the same state on matching errors, with exponential backoff.
- Catch — on unrecoverable error, route to a fallback state (compensating action, alert, cleanup).
This is the saga pattern made declarative: each Task can Catch into a compensation state.
Input/output processing
The most confusing part. Four filters, applied in order:
raw input
→ InputPath select a portion of the input to pass to the state
→ Parameters construct the actual input to the task (can reference $$ context)
→ [task runs]
→ ResultSelector reshape the task's raw result
→ ResultPath where to put the result in the state's input ($ = replace, $.x = merge into x)
→ OutputPath select a portion of the combined result to pass to the next state
→ output
The common gotcha: ResultPath: "$" (default) replaces the entire state input with the task result — you lose the original input. To keep the input and add the result, use ResultPath: "$.result".
"ChargeCard": {
"Type": "Task",
"Resource": "...",
"ResultPath": "$.chargeResult", // keep original input, add result under .chargeResult
"Next": "FulfillOrder"
}
Map state — and Distributed Mode
Map runs the same sub-workflow over each element of an array.
- Inline Map — up to 40 concurrent iterations; iteration state held in the execution. For small batches.
- Distributed Map — for large-scale fan-out: process millions of items (e.g., every object in an S3 bucket), up to 10,000 concurrent executions. Each iteration is a child execution; results aggregated. This replaces “Lambda fan-out via SQS” for big batch jobs.
"ProcessFiles": {
"Type": "Map",
"ItemReader": {
"Resource": "arn:aws:states:::s3:listObjectsV2",
"Parameters": {"Bucket": "my-data-bucket"}
},
"ItemProcessor": {
"ProcessorConfig": {"Mode": "DISTRIBUTED", "ExecutionType": "EXPRESS"},
"StartAt": "ProcessOne",
"States": {"ProcessOne": {"Type": "Task", "Resource": "...", "End": true}}
},
"MaxConcurrency": 1000,
"End": true
}
Direct SDK integrations — no Lambda needed
Step Functions can call ~200 AWS services directly. You don’t need a Lambda just to PutItem to DynamoDB or send an SNS message.
"SaveOrder": {
"Type": "Task",
"Resource": "arn:aws:states:::dynamodb:putItem",
"Parameters": {
"TableName": "Orders",
"Item": {"orderId": {"S.$": "$.orderId"}, "status": {"S": "confirmed"}}
},
"Next": "NotifyUser"
}
Removes a whole class of trivial “glue” Lambdas. Cheaper, fewer cold starts, less code.
Callback pattern with task tokens
For steps that wait on something external (human approval, a third-party webhook, a long async job):
"WaitForApproval": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
"Parameters": {
"FunctionName": "request-approval",
"Payload": {"taskToken.$": "$$.Task.Token", "orderId.$": "$.orderId"}
},
"Next": "Fulfill"
}
Step Functions pauses the execution and passes a task token. Something external eventually calls SendTaskSuccess(taskToken, output) or SendTaskFailure(taskToken) to resume it. The execution can wait up to a year (Standard) without consuming compute.
Step Functions vs Celery vs Temporal vs in-app orchestration
| Step Functions | Celery | Temporal | In-app code | |
|---|---|---|---|---|
| Hosting | fully managed | you run broker + workers | self-host or Temporal Cloud | none |
| Workflow visibility | execution history in console | no (just task results) | full event history | none |
| Long-running / human-in-loop | yes (task tokens, 1 year) | awkward | yes (durable, first-class) | hard |
| Retries / compensation | declarative | per-task config | code with try/except | you write it |
| Language | JSON (ASL) | Python | Python/Go/Java/TS code | Python |
| Lock-in | AWS | none | none | none |
| Best for | AWS-native orchestration, visible business processes | background jobs, fire-and-forget tasks | complex durable workflows, multi-language, portability | trivial sequencing only |
Decision shortcut:
- AWS-native shop, want a visible auditable workflow with branching/retries → Step Functions.
- Background jobs / async tasks, no orchestration → Celery.
- Complex long-running workflows, want code not JSON, need portability → Temporal.
- Two sequential steps, no failure complexity → just call them in app code.
Common gotchas
ResultPathdefault replaces the whole input — use$.somethingto merge instead.- Express at-least-once — steps run more than once; idempotency is your job.
- State machine size limits — ASL definition has size limits; very large machines need to be decomposed (nested state machines).
- Per-state-transition pricing (Standard) — a workflow with thousands of states gets expensive; use Express for high-volume.
- Lambda payload limits — 256 KB between states; pass S3 references for big payloads.
- Debugging JSON — ASL is verbose and JSON has no comments; the Workflow Studio visual editor helps. Consider CDK or the
aws-stepfunctionsconstructs to generate it.
Interview angle
- “Express vs Standard?” — Standard: up to 1 year, exactly-once, full history, per-transition pricing — for auditable business processes. Express: ≤5 min, at-least-once, CloudWatch-only history, cheap at volume — for high-throughput event processing (make steps idempotent).
- “How does error handling work?” — declarative
Retry(exponential backoff on matching errors) andCatch(route to a fallback/compensation state). This makes the saga pattern declarative instead of hand-coded. - “Explain
ResultPath.” — controls where a task’s result lands in the state’s data. Default$replaces the entire input with the result — common bug. Use$.resultto keep the input and merge the result alongside. - “What’s the callback pattern?” —
waitForTaskToken: Step Functions pauses, hands out a task token, and resumes only when something external callsSendTaskSuccess/SendTaskFailure. For human approval or long async waits — can pause up to a year without compute cost. - “Step Functions vs Celery vs Temporal?” — Step Functions for AWS-native, visible, JSON-defined orchestration; Celery for background jobs with no orchestration needs; Temporal for complex durable workflows in code with portability. Don’t use any of them for two trivial sequential steps.
- “When would you use Distributed Map?” — large-scale parallel batch over millions of items (e.g., every object in an S3 bucket), up to 10k concurrent child executions. Replaces hand-rolled Lambda + SQS fan-out.