AWS Integration

AWS Step Functions: Orchestrating Workflows with State Machines

You have a business process that is really five or six steps: validate an order, charge a card, reserve inventory, fan out to notify three systems, wait for a human to approve a refund, then write the result. On a whiteboard it is a clean flowchart. In code it becomes a Lambda that calls another Lambda, an SQS queue bolted on for retries, a DynamoDB “where are we?” table, a dead-letter queue nobody watches, and a tangle of try/except that swallows the one error you actually needed to see. Six months later nobody can answer “which orders are stuck, and at which step?” without grepping CloudWatch across five log groups.

AWS Step Functions is the managed answer to that pain. You describe the workflow once as a state machine — a JSON document written in the Amazon States Language (ASL) — and the service becomes the coordinator: it invokes each step, passes data between them, retries transient failures with exponential backoff, catches errors and routes them to a handler, runs steps in parallel or fans them out across a list, waits (for seconds or for a human), and records a complete, per-step execution history you can replay. Your Lambda functions go back to doing one thing each; the orchestration lives in a diagram you can actually read.

This is an advanced, practitioner-grade tour. You will learn the ASL grammar top to bottom, when to pick Standard versus Express workflows and what each guarantees, every state type (Task, Choice, Parallel, Map, Distributed Map, Wait, Pass, Succeed, Fail), how Retry and Catch actually evaluate, the three service-integration patterns (request-response, .sync, .waitForTaskToken), the full input/output pipeline in both JSONPath and the newer JSONata mode, and how to observe and debug executions. Then you will build a real Standard workflow end to end with the aws CLI and Terraform, run it with success and failure inputs, read the history, and work a twelve-row troubleshooting playbook. If you have read AWS Lambda event-driven patterns and event-driven architecture with EventBridge, SQS, and Lambda, this is the piece that ties those choreographed events into an orchestrated, observable whole.

What problem this solves

Event choreography — services reacting to each other’s events through EventBridge and SQS — is loosely coupled and scales beautifully, but it has no single place that knows the state of the whole process. When a five-step saga half-completes, you have a charged card and no reserved inventory, and reconstructing “what happened” means correlating events across buses and queues. Orchestration flips that: one component owns the sequence, the branching, the retries, and the compensation. Step Functions is that component, delivered as a fully managed, serverless service with no coordinator to run or patch.

Without an orchestrator, teams reinvent it badly. The failure modes are always the same: retry logic copy-pasted (and subtly wrong) into every function; a step that silently succeeds-but-does-nothing because an exception was swallowed; no timeout, so a hung downstream call wedges the whole flow for hours; and no audit trail, so compliance asks “prove this refund was approved before it was paid” and you cannot. You also hit the long-running wall: a Lambda caps at 15 minutes, but real processes wait on a batch job, a manual approval, or a partner callback that takes hours or days. You cannot hold a Lambda open for that; you need a coordinator that can wait cheaply.

Who hits this: anyone building order pipelines, ETL and data-processing jobs, media transcoding, ML training/inference pipelines, human-in-the-loop approvals, microservice sagas with compensation, infrastructure automation, and batch fan-out over millions of records. If your architecture has the words “and then,” “in parallel,” “for each,” “retry,” “wait for,” or “if it fails, undo,” you have an orchestration problem, and Step Functions is the AWS-native tool for it.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable with IAM roles and policies, reading and writing JSON, invoking Lambda, and the basics of at least one downstream service (SQS, SNS, DynamoDB, or ECS). You need the AWS CLI v2 configured, Terraform 1.6+ if you want the IaC path, and permission to create IAM roles, Lambda functions, and Step Functions state machines. Nothing here needs anything beyond the free tier except where a ⚠️ note says otherwise.

Where it sits: Step Functions is the orchestration layer of AWS’s application-integration family. It usually sits above compute (Lambda, ECS/Fargate, Batch, EC2) and calls those to do work, and it sits alongside the messaging services — EventBridge routes and starts executions, SQS buffers work between steps, SNS fans out notifications. A common rule of thumb: use EventBridge to decide which workflow runs in response to an event, then use Step Functions to run the steps of that workflow. For the messaging half of that picture, see the companion pieces on EventBridge rules and event buses, and on SQS standard and FIFO queues; this article assumes you know what a rule and a queue are and focuses on the state machine that consumes and coordinates them.

Core concepts

A state machine is a JSON document (ASL) describing a graph of states. An execution is one run of that graph against a specific input; you can have thousands of executions of the same state machine in flight at once, each with its own data. Each state does one thing — invoke a task, make a choice, wait, run branches — and either transitions to a Next state or is a terminal state (End: true, or a Succeed/Fail state). Data flows through as JSON: each state receives an input, may transform it, and emits an output that becomes the next state’s input.

Term What it is Why it matters
State machine The ASL definition — the workflow template Versioned, deployable; up to 1 MB of JSON
Execution One run of a state machine against one input Has an ARN, a status, and a full history
State One node: a step or a control-flow construct The unit of retry, catch, timeout, and history
Transition Moving from one state to the next (Next/End) The unit Standard workflows are billed on
Task A state that does external work Where Lambda / service integrations plug in
Task token An opaque string that pauses a task until a callback Enables human-in-the-loop and async jobs
Activity A poll-based worker registered with Step Functions Older callback style for self-hosted workers
Execution role The IAM role Step Functions assumes to call services Every integration needs a permission here
Context object Runtime metadata ($$) — execution id, task token, map index Read it to pass identity/token into a task
Intrinsic function Built-in ASL helpers (States.Format, States.Array, …) Light data shaping without a Lambda

Two things trip up newcomers. First, Step Functions is not a compute service — it does not run your code. It calls things that run code (Lambda, ECS tasks, Batch jobs) or that do work (SNS publish, DynamoDB PutItem). A “Task” is a call, not a container. Second, all data is JSON and every state can reshape it. Half of learning Step Functions is learning the input/output pipeline so you stop stuffing a Lambda in just to move a field.

Top-level ASL field Required? Meaning Notes / limit
Comment No Human description Ignored at runtime
StartAt Yes Name of the first state Must match a key in States
States Yes Map of state name → state object State names unique per scope, ≤ 80 chars
Version No ASL version Defaults to "1.0"
TimeoutSeconds No Whole-execution timeout Fails with States.Timeout if exceeded
QueryLanguage No JSONPath (default) or JSONata Set per machine or per state

Amazon States Language: the grammar

Every state, whatever its type, shares a small set of common fields, then adds type-specific ones. Learn the common set once and the rest is vocabulary.

Common field Applies to Purpose Default
Type All The state type (see next table) none (required)
Comment All Inline documentation none
Next Non-terminal Name of the next state none
End Non-terminal true marks this a terminal state false
InputPath Most JSONPath selecting part of the input $ (whole input)
OutputPath Most JSONPath selecting part of the output $ (whole output)
Parameters Task, Map, Parallel, Pass Build the input to the work none
ResultSelector Task, Map, Parallel Reshape the raw result none
ResultPath Task, Map, Parallel, Pass Where to place the result in the input $ (replace)
Retry Task, Map, Parallel Retry rules on error none
Catch Task, Map, Parallel Fallback states on error none
QueryLanguage All Override machine-level language inherit
Assign Most (JSONata & JSONPath) Set workflow variables none

There are eight state types. Six do control flow or data; two — Task and, at scale, Map — do the actual work.

State type Category One-line job Can Retry/Catch?
Task Work Call a service / Lambda / activity Yes
Choice Branch if/else if/else on the data No
Parallel Concurrency Run N fixed branches at once Yes
Map Concurrency Run one branch per item in a list Yes
Wait Delay Pause for a duration or until a timestamp No
Pass Data Inject/transform data, no external call No
Succeed Terminal Stop this branch successfully No
Fail Terminal Stop with an error name and cause No

A minimal but complete two-state machine, in JSONPath mode, looks like this:

{
  "Comment": "Smallest useful workflow",
  "StartAt": "DoWork",
  "States": {
    "DoWork": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": { "FunctionName": "my-func", "Payload.$": "$" },
      "Retry": [
        { "ErrorEquals": ["States.TaskFailed"], "IntervalSeconds": 2,
          "MaxAttempts": 3, "BackoffRate": 2.0 }
      ],
      "Catch": [
        { "ErrorEquals": ["States.ALL"], "Next": "Failed" }
      ],
      "Next": "Done"
    },
    "Done": { "Type": "Succeed" },
    "Failed": { "Type": "Fail", "Error": "WorkFailed", "Cause": "See history" }
  }
}

Note the .$ suffix on Payload.$: in JSONPath mode, a key ending in .$ means “the value is a path or intrinsic function to evaluate,” not a literal string. That one convention explains most “why is my field the literal text $.orderId?” confusion.

Standard vs Express workflows

You choose the workflow type when you create the state machine, and it is immutable — to switch, you create a new machine. The choice drives duration limits, the delivery guarantee, how you observe runs, and the entire pricing model.

Dimension Standard Express
Max duration 1 year 5 minutes
Execution model Exactly-once At-least-once
Start rate ~2,000 executions/s (soft, raise via quota) ~100,000 executions/s (soft)
State-transition rate High but metered Effectively unmetered
Pricing basis Per state transition Per request + duration (GB-second)
Execution history Full, durable, in console + API Not in history API; CloudWatch Logs only
Idempotency Guaranteed by the service You must build it
Sub-modes Async only Synchronous or asynchronous
Redrive / replay Redrive supported No redrive
Typical use Long, stateful, auditable orchestration High-volume, short, event processing

The delivery-guarantee line is the one that bites in production. Standard is exactly-once: each state transition happens once, the service durably tracks progress, and you can trust that a step ran once. Express is at-least-once: on internal retries a step can run more than once, so any Task with a side effect (a charge, a write, an email) must be idempotent. Use an idempotency key or a conditional write.

Express further splits by how you start it:

Express sub-mode How you start it Returns result? Guarantee Use it for
Synchronous StartSyncExecution (or API Gateway integration) Yes — waits and returns output At-most-once from caller’s view Request/response behind an API
Asynchronous StartExecution No — fire and forget At-least-once High-volume event ingestion

A decision table for the type:

If you need… Choose Because
A run that can last minutes-to-a-year Standard Express caps at 5 minutes
A full, replayable audit trail Standard Express has no execution history
Human approval / long callback Standard The wait outlives Express’s 5 min
Exactly-once side effects for free Standard Express is at-least-once
10,000+ short runs per second Express Standard’s start rate is lower and pricier
Cheapest per-run at high volume Express Per-transition pricing punishes chatty Standard flows
A synchronous API backend Express (sync) Returns the result to the caller
An IoT/clickstream processor Express (async) Throughput and cost win

A powerful pattern combines both: a Standard parent orchestrates the long-lived, auditable process and, for a hot inner loop that runs millions of times, starts a nested Express child (arn:aws:states:::states:startExecution.sync). You get exactly-once orchestration at the top and cheap throughput at the bottom.

The state types in depth

Task

A Task is where work happens. Resource names what to call and which pattern to use; the suffix on the ARN (none, .sync, .waitForTaskToken) selects the integration pattern (covered below).

Task field Purpose Default / limit
Resource ARN of the integration or Lambda Required
Parameters / Arguments Build the payload sent to the service none
TimeoutSeconds / TimeoutSecondsPath Fail the task after N seconds 60 s for activities; none for many
HeartbeatSeconds Require a heartbeat within N seconds none
Retry Retry rules (array, ordered) none
Catch Fallback states (array, ordered) none
ResultSelector Reshape the raw result none
ResultPath Where to place the result $
Credentials Cross-account role to assume for this task none

Always set a TimeoutSeconds on a Task. Without one, a hung integration can leave a Standard execution “running” for a very long time, and a stuck .waitForTaskToken can wait up to a year.

Choice

A Choice state is your if / else if / else. It has an ordered Choices array — the first rule whose condition is true wins — and a Default to fall through to. Omit the Default and an unmatched input fails the execution with States.NoChoiceMatched. That is the single most common Choice bug.

Comparison family Operators (each has a …Path variant)
String StringEquals, StringLessThan, StringGreaterThan, StringLessThanEquals, StringGreaterThanEquals, StringMatches (wildcards)
Numeric NumericEquals, NumericLessThan, NumericGreaterThan, NumericLessThanEquals, NumericGreaterThanEquals
Boolean BooleanEquals
Timestamp TimestampEquals, TimestampLessThan, TimestampGreaterThan, TimestampLessThanEquals, TimestampGreaterThanEquals
Presence / type IsPresent, IsNull, IsBoolean, IsNumeric, IsString, IsTimestamp
Logical And, Or, Not (nest rules)
Choice rule field Meaning
Variable JSONPath to the value being tested
<operator> The comparison and its literal (or …Path for a second path)
And / Or / Not Combine nested rules
Next State to go to if this rule matches
Default (state-level) Fallback when no rule matches

Guard value comparisons with a presence check: test IsPresent: true (and IsNumeric/IsString) before comparing, or a missing field throws States.Runtime instead of taking the branch you expect.

Parallel

A Parallel state runs a fixed set of Branches concurrently; each branch is its own mini state machine. The state’s output is an array with one element per branch, in order. If any branch fails and nothing catches it, the whole Parallel state fails with States.BranchFailed and the other branches are stopped.

Parallel field Purpose
Branches Array of sub-workflows (StartAt + States)
ResultPath Where to put the array of branch outputs
Retry / Catch Apply to the Parallel state as a whole
ResultSelector Reshape the combined array

Use Parallel for a known, small number of independent steps (fraud check and inventory check and tax calc). Use Map when the number of things depends on the input.

Map (inline) and Distributed Map

A Map state runs the same ItemProcessor once per element of an input array. It has two modes, and choosing wrong is a real scaling trap.

Aspect Inline Map (Mode: INLINE) Distributed Map (Mode: DISTRIBUTED)
Item source A JSON array already in state (ItemsPath) An array in state or an S3 dataset (ItemReader)
Scale Up to ~40 concurrent iterations Up to 10,000 concurrent child executions
Item count Bounded by the 256 KB state limit Millions of items (S3 CSV/JSON/JSONL/manifest/inventory)
Each iteration Runs in the parent execution & history Runs as a child execution (own history)
History impact Adds to the parent’s 25,000-event cap Isolated per child — no parent-history blowup
Batching No ItemBatcher — group items per child
Failure tolerance All-or-nothing (via Catch) ToleratedFailurePercentage / Count
Results In parent output Optional ResultWriter to S3
Child type n/a Express (fast/cheap) or Standard
Map field Applies to Purpose
ItemsPath Both JSONPath to the input array
ItemProcessor Both The per-item sub-workflow (+ ProcessorConfig.Mode)
MaxConcurrency / MaxConcurrencyPath Both Cap parallel iterations (0 = unbounded)
ItemSelector Both Build each item’s input (replaces legacy Parameters)
ItemReader Distributed Read items from S3 (format + resource)
ItemBatcher Distributed Batch items to reduce child-execution count
ResultWriter Distributed Write aggregated results to an S3 bucket
ToleratedFailurePercentage / Count Distributed Allow some item failures before failing the Map

Reach for Distributed Map the moment you are iterating over more than a few hundred items, over data that lives in S3, or over anything that would bloat the parent execution history. It is the purpose-built tool for “process every object in this bucket / every row in this CSV” at high concurrency.

Wait, Pass, Succeed, Fail

Wait mode Field Behaviour
Relative Seconds / SecondsPath Pause N seconds
Absolute Timestamp / TimestampPath Pause until an ISO-8601 instant

In Standard workflows a Wait is free of compute cost — the service just holds the execution — so a workflow can cheaply sleep for hours or days (poll a job, back off, wait for a scheduled window).

State Key fields Use
Pass Result, Parameters, ResultPath Inject constants or reshape data without a call; great for testing a graph
Succeed (none) End a branch successfully; stops that path
Fail Error, Cause (+ …Path variants) End with a named error a Catch upstream can match

Error handling: Retry and Catch

This is the heart of resilient orchestration. On an error, Step Functions evaluates the state’s Retry array first; if retries are exhausted or none match, it evaluates the Catch array; if neither handles it, the error propagates and (unless caught by an enclosing Parallel/Map) fails the execution.

Retry field Meaning Default
ErrorEquals Array of error names this rule handles required
IntervalSeconds Delay before the first retry 1
MaxAttempts Retries after the first failure (0 = don’t) 3
BackoffRate Multiplier applied to the interval each attempt 2.0
MaxDelaySeconds Cap the computed backoff delay none
JitterStrategy FULL randomises delay; NONE is deterministic NONE

Retries are evaluated in array order, and each error is matched against the first Retry rule that lists it. Put specific errors first and a broad States.ALL last. With BackoffRate: 2.0 and IntervalSeconds: 2, retries wait 2s, 4s, 8s; add JitterStrategy: FULL so a fleet of executions retrying at once don’t stampede the downstream in lockstep.

Catch field Meaning Default
ErrorEquals Array of error names this catcher handles required
Next State to transition to on catch required
ResultPath Where to place the error object in the input $ (replaces input!)

The ResultPath on a Catch is critical: leave it at the default $ and the error object overwrites your entire input, so your fail-handler loses the order it was working on. Set ResultPath: "$.error" to keep the original input and attach the error under .error.

Error names are strings. Some are predefined by the service; others come from your Lambda (its errorType) or a Fail state’s Error.

Predefined error name Fires when
States.ALL Matches any error (use last)
States.Runtime An unrecoverable runtime error (bad path, intrinsic failure) — not retriable
States.TaskFailed A Task reported a failure
States.Timeout Task exceeded TimeoutSeconds or missed a heartbeat
States.HeartbeatTimeout No heartbeat within HeartbeatSeconds
States.Permissions The execution role lacks permission for the call
States.DataLimitExceeded Input/output/state exceeded 256 KB
States.ResultPathMatchFailure ResultPath couldn’t apply to the data
States.ParameterPathFailure A Parameters path couldn’t be resolved
States.BranchFailed A Parallel branch failed
States.NoChoiceMatched A Choice matched nothing and had no Default
States.IntrinsicFailure An intrinsic function threw
States.ExceedToleratedFailureThreshold Distributed Map passed its failure tolerance
States.ItemReaderFailed Distributed Map couldn’t read the S3 dataset
States.ResultWriterFailed Distributed Map couldn’t write results to S3
States.QueryEvaluationError A JSONata expression failed to evaluate

A subtlety worth internalising: States.Runtime is not retriable — retrying a malformed ResultPath or a missing field will never succeed, so a Retry on States.ALL that includes it just wastes time. Catch it and route to a handler instead.

Service integrations and the three patterns

A Task’s Resource ARN both names the service and picks the integration pattern via a suffix. The pattern decides when the Task is considered done and what the next state receives.

Pattern ARN suffix Task completes when… Use for
Request-Response none (e.g. sns:publish) The API call returns Fire a call, keep going
Run a Job (.sync) .sync / .sync:2 The underlying job finishes ECS task, Glue/EMR/Batch job, nested SM
Wait for Callback .waitForTaskToken Someone calls SendTaskSuccess with the token Human approval, async partner, activity worker

Request-response is the default: Step Functions makes the API call and immediately moves on with the response — it does not wait for any downstream work the call kicked off. If you ecs:runTask (no suffix) you get back “task started,” not “task finished.”

.sync (run-a-job) is what you usually want for long jobs: ecs:runTask.sync, glue:startJobRun.sync, batch:submitJob.sync, emr:addStep.sync, states:startExecution.sync. Step Functions polls (via a managed EventBridge rule) and holds the Task until the job reaches a terminal state, then returns its result. This is why .sync tasks need extra IAM permissions — the role must be allowed to create the managed rule and describe the job.

.waitForTaskToken pauses the Task indefinitely. Step Functions injects a $$.Task.Token into the payload; your code, a human, or a partner system carries that token and later calls SendTaskSuccess / SendTaskFailure (or SendTaskHeartbeat). Until then the execution waits — set HeartbeatSeconds and TimeoutSeconds so a lost token doesn’t hang for a year.

"ApproveRefund": {
  "Type": "Task",
  "Resource": "arn:aws:states:::sns:publish.waitForTaskToken",
  "Parameters": {
    "TopicArn": "arn:aws:sns:us-east-1:111122223333:approvals",
    "Message": { "orderId.$": "$.orderId", "taskToken.$": "$$.Task.Token" }
  },
  "TimeoutSeconds": 86400,
  "HeartbeatSeconds": 3600,
  "Next": "Fulfill"
}

Beyond patterns, there are two families of integration:

Family ARN shape Coverage When to use
Optimized arn:aws:states:::<svc>:<action> ~40 curated services, richer params & .sync First choice — cleaner, supports run-a-job
AWS SDK arn:aws:states:::aws-sdk:<svc>:<action> 200+ services, 9,000+ API actions When there’s no optimized integration
Optimized integration Request-Response .sync .waitForTaskToken
Lambda invoke Yes Yes
SNS publish Yes Yes
SQS sendMessage Yes Yes
DynamoDB getItem/putItem Yes
ECS/Fargate runTask Yes Yes Yes
AWS Batch submitJob Yes Yes
Glue startJobRun Yes Yes
EMR addStep Yes Yes
SageMaker createTrainingJob Yes Yes
Step Functions startExecution (nested) Yes Yes Yes
API Gateway invoke Yes Yes
EventBridge putEvents Yes Yes

If a service or action isn’t in the optimized list, the AWS SDK integration almost certainly has it — arn:aws:states:::aws-sdk:s3:listObjectsV2, …:aws-sdk:secretsmanager:getSecretValue, and so on — so you rarely need a Lambda just to make a plain AWS API call anymore.

Input and output processing

Between a state’s raw input and the next state’s input, JSONPath-mode Step Functions applies up to five filters, in a fixed order. Knowing the order eliminates most “my data is the wrong shape” bugs.

# Field Applies to What it does
1 InputPath The raw input Select a subset of the input to work with
2 Parameters The selected input Build the payload for the task (with .$)
3 ResultSelector The task’s raw result Reshape what the task returned
4 ResultPath Input + selected result Place the result into the input JSON
5 OutputPath The combined JSON Select the final output to pass on

A worked example: the input is { "order": {...}, "meta": {...} }. InputPath: "$.order" narrows to the order. Parameters builds { "FunctionName": "...", "Payload.$": "$" }. The Lambda returns { "Payload": { "score": 0.9 }, "StatusCode": 200 }; ResultSelector: { "score.$": "$.Payload.score" } trims it to { "score": 0.9 }. ResultPath: "$.risk" attaches that under .risk so the order survives. OutputPath: "$" passes it all on. Miss the ResultPath and the Lambda’s response would replace your order.

JSONata mode and variables

In late 2024 Step Functions added JSONata as an alternative query language, and variables. Set "QueryLanguage": "JSONata" on the machine (or a state) and the model simplifies dramatically: instead of the five-field JSONPath pipeline you use Arguments (input to the task) and Output (the state’s output), writing expressions inline as {% ... %}.

Concern JSONPath mode JSONata mode
Select input InputPath {% $states.input %} in expressions
Build task input Parameters (with .$) Arguments
Reshape result ResultSelector inline in Output
Place result ResultPath not needed — you build Output
Final output OutputPath Output
Expressions JSONPath + intrinsics Full JSONata (filters, maps, math, string fns)
Set variables Assign Assign

Variables solve the old headache of “I need a value from step 1 in step 6 but step 3 reshaped the state.” Use Assign to store a value in a named variable, then reference $myVar anywhere downstream regardless of how the main data has been transformed — no more threading a field through every ResultPath just to keep it alive.

"LoadConfig": {
  "Type": "Pass",
  "QueryLanguage": "JSONata",
  "Assign": { "orderId": "{% $states.input.orderId %}", "region": "us-east-1" },
  "Output": "{% $states.input %}",
  "Next": "Process"
}

For light shaping without a Lambda or JSONata, JSONPath mode ships intrinsic functions:

Intrinsic Does
States.Format(tmpl, …) String interpolation
States.StringToJson(str) Parse a JSON string
States.JsonToString(obj) Serialize to a JSON string
States.Array(a, b, …) Build an array
States.ArrayGetItem(arr, i) Index into an array
States.ArrayLength(arr) Length
States.ArrayPartition(arr, n) Chunk an array (batching)
States.ArrayContains(arr, v) Membership test
States.MathAdd(a, b) Integer add
States.MathRandom(lo, hi) Random integer
States.StringSplit(str, delim) Split
States.UUID() Generate a UUID (great idempotency key)
States.Hash(data, algo) Hash a value
States.JsonMerge(a, b, deep) Merge two objects

And the context object ($$) exposes runtime metadata you often need inside a Task:

Context path Value
$$.Execution.Id This execution’s ARN
$$.Execution.Input The original execution input
$$.Execution.StartTime ISO-8601 start time
$$.State.Name Current state’s name
$$.State.EnteredTime When this state was entered
$$.StateMachine.Id The state machine ARN
$$.Task.Token The callback token (.waitForTaskToken only)
$$.Map.Item.Value Current item (inside a Map)
$$.Map.Item.Index Current item’s index

Observability

Standard executions record a complete execution history — every state entered/exited, every task scheduled/started/succeeded/failed, with input and output at each step. This is the single best debugging asset in the service: open a failed run and you can see the exact state, the exact input it received, and the exact error, without correlating logs.

History event (representative) Marks
ExecutionStarted The run began (with input)
TaskStateEntered / TaskStateExited Entering/leaving a Task state
TaskScheduled / TaskStarted / TaskSucceeded / TaskFailed The integration lifecycle
LambdaFunctionScheduled / …Succeeded / …Failed Lambda-specific lifecycle
ChoiceStateEntered / …Exited Choice evaluation
MapStateEntered / MapIterationStarted Map fan-out
ParallelStateEntered / ParallelStateExited Parallel branches
ExecutionSucceeded / ExecutionFailed / ExecutionAborted / ExecutionTimedOut Terminal outcome

CloudWatch metrics (namespace AWS/States, per state machine) let you alarm on health:

Metric Watch for
ExecutionsStarted Baseline volume
ExecutionsSucceeded Success count
ExecutionsFailed Failures — alarm on this
ExecutionsAborted Manual/other stops
ExecutionsTimedOut Hit TimeoutSeconds
ExecutionThrottled You’re over the start-rate quota
ExecutionTime Duration distribution
ActivitiesScheduled / …TimedOut Activity-worker health
ExpressExecutionBilledDuration Express cost driver

Express workflows have no execution history API — you must enable CloudWatch Logs to see anything. Standard can optionally log to CloudWatch too. Logging has levels, and X-Ray gives you a distributed trace across the whole flow.

Log level Emits
ALL Every event (verbose; use in dev)
ERROR Failed/aborted states only
FATAL Only execution-fatal events
OFF Nothing

Set includeExecutionData: true to log inputs/outputs (careful with PII), and enable X-Ray (tracingConfiguration.enabled = true) to see the Lambda and service calls as a service map with latency per hop.

Architecture at a glance

The diagram below is the exact workflow you build in the lab, drawn as the real left-to-right request path. A run starts (blue) from the CLI, from API Gateway, or from an EventBridge rule that targets the state machine. Inside the Standard state machine (purple), the first Task invokes a Validate Lambda wrapped in Retry (backoff + jitter) and Catch, then a Choice routes on the order’s tier with a mandatory Default. The flow fans out through a Map (green) that runs a worker per item under a MaxConcurrency cap, then a Parallel state (amber) runs independent enrichers; a .waitForTaskToken node shows where a human/async callback would pause the run, and a Catch on States.ALL (red) diverts any unhandled error. The run ends (teal) either at a Succeed state that returns the result to the caller or at a fail-handler Lambda that records the failure before a Fail state stops the run with an error name and cause. The six numbered badges mark the failure classes you will actually debug — retry storms, a Choice with no default, an over-fanned Map, an unhandled error, a stuck callback token, and Express’s at-least-once duplicates.

AWS Step Functions Standard state machine orchestration path: an execution starts from the CLI, API Gateway, or an EventBridge rule and flows through a Lambda Validate Task with Retry and Catch, a Choice with a Default, a Map fan-out capped by MaxConcurrency, a Parallel state with a waitForTaskToken callback and a States.ALL Catch, ending at a Succeed state or a fail-handler Lambda leading to a Fail state, with six numbered badges over the retry, choice-default, map-concurrency, catch, callback, and exactly-once failure points

Real-world scenario

Northwind Retail runs order fulfilment for a mid-size e-commerce brand: about 40,000 orders a day, spiking to 250,000 on sale days. Their original pipeline was six Lambdas chained by SNS and SQS, with a DynamoDB “order_state” table each function updated to record progress. It worked until it didn’t: during a flash sale, a payment-provider slowdown caused Lambda-to-Lambda calls to time out and retry uncontrollably, double-charging some customers because the “charge” function wasn’t idempotent and the retries were uncoordinated. Worse, when support asked “which orders are stuck?”, the only answer was a DynamoDB scan filtered on a status field that three functions wrote inconsistently.

They rebuilt on Step Functions. The order pipeline became one Standard state machine: ValidateOrder (Lambda Task, Retry on Lambda.TooManyRequestsException with jitter) → ChargePayment (a .waitForTaskToken Task so the payment service’s async webhook completes the step exactly once) → a Choice on order value routing high-value orders to a .waitForTaskToken manual fraud review → a Map over line items to reserve inventory, capped at MaxConcurrency: 10 to protect the inventory service → a Parallel state firing the receipt email, the warehouse pick-list, and the analytics event at once → Succeed. Every state has a Catch on States.ALL routing to a CompensateAndAlert handler that reverses partial work (release inventory, void the charge) and pages on-call.

The payoff was immediate and measurable. Exactly-once execution killed the double-charge class of bug outright — the .waitForTaskToken charge step cannot run twice. “Which orders are stuck and where?” became a console filter on executions by status, with each stuck run showing the exact state and input. When a downstream service degrades, the Retry-with-jitter backs off instead of stampeding, and the failure tolerance is explicit rather than emergent. For the sale-day analytics fan-out over the full day’s orders (millions of line-item events), they added a nightly Distributed Map that reads the day’s order export from S3, batches 100 items per child, runs an Express child per batch at up to 3,000 concurrency, and writes aggregates back to S3 — a job that used to be a fragile 6-hour EMR script now finishes in 25 minutes with an explicit 0.5% tolerated-failure threshold and a results manifest. Their Step Functions bill for the transactional pipeline runs about ₹9,000–₹14,000/month; the leadership-visible win was that mean-time-to-diagnose a stuck order dropped from hours of log-grepping to a single console click.

Advantages and disadvantages

Advantages Disadvantages
Visual, auditable workflow — history per step ASL JSON is verbose; a learning curve
Built-in retry, catch, timeout, backoff, jitter Per-transition pricing punishes chatty Standard flows
Exactly-once (Standard) removes a whole bug class Express is at-least-once — you build idempotency
Long-running (up to 1 year) and cheap waits 256 KB state payload limit forces S3 offload
220+ services via SDK integrations, no glue Lambdas Vendor lock-in — ASL is AWS-specific
Distributed Map scales to millions of items 25,000-event history cap on huge Standard runs
Serverless — no coordinator to run/patch Debugging complex JSONPath transforms is fiddly
Human-in-the-loop via task tokens Express needs CloudWatch Logs for any visibility

When do the disadvantages matter? The per-transition cost bites when a Standard workflow has many small, fast steps at high volume — that is exactly the signal to switch that portion to Express or collapse steps. The 256 KB limit bites in data pipelines that try to pass whole documents between states — the fix is always to pass an S3 pointer, never the payload. And the lock-in is real: if multi-cloud portability is a hard requirement, an open orchestrator (Temporal, Airflow) may win despite more operational burden.

Hands-on lab

You will build the workflow from the diagram: a Standard state machine with a Lambda Task (Retry + Catch) → ChoiceMap fan-out → Parallel → a fail-handler path. Then run it with a success input and a failure input and read the history. Everything here is free-tier-friendly. ⚠️ Costs are negligible (a few thousand state transitions and a handful of Lambda invokes), but delete the state machine and Lambdas at the end so nothing lingers.

Step 1 — Set variables and create the Lambda execution role

export AWS_REGION=us-east-1
export ACCT=$(aws sts get-caller-identity --query Account --output text)

# Trust policy for Lambda
cat > lambda-trust.json <<'JSON'
{ "Version": "2012-10-17", "Statement": [
  { "Effect": "Allow", "Principal": { "Service": "lambda.amazonaws.com" },
    "Action": "sts:AssumeRole" } ] }
JSON

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

aws iam attach-role-policy --role-name sf-lab-lambda-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

Step 2 — Create the worker and fail-handler Lambdas

The worker echoes its input and throws when it sees "fail": true, so the failure run exercises Retry and Catch.

mkdir -p fn && cat > fn/worker.py <<'PY'
def lambda_handler(event, context):
    payload = event if isinstance(event, dict) else {}
    if payload.get("fail") is True:
        raise Exception("WorkerError: simulated downstream failure")
    payload["processed"] = True
    return payload
PY
( cd fn && zip -q worker.zip worker.py )

cat > fn/failh.py <<'PY'
def lambda_handler(event, context):
    print("FAIL HANDLER received:", event)
    return {"handled": True, "error": event.get("error", {})}
PY
( cd fn && zip -q failh.zip failh.py )

ROLE_ARN=arn:aws:iam::${ACCT}:role/sf-lab-lambda-role
sleep 10  # let the role propagate

aws lambda create-function --function-name sf-lab-worker \
  --runtime python3.12 --handler worker.lambda_handler \
  --role $ROLE_ARN --zip-file fileb://fn/worker.zip --timeout 10

aws lambda create-function --function-name sf-lab-fail-handler \
  --runtime python3.12 --handler failh.lambda_handler \
  --role $ROLE_ARN --zip-file fileb://fn/failh.zip --timeout 10

Step 3 — Create the state machine execution role

The role must let Step Functions invoke exactly the two functions — least privilege, not lambda:* on *.

cat > sfn-trust.json <<'JSON'
{ "Version": "2012-10-17", "Statement": [
  { "Effect": "Allow", "Principal": { "Service": "states.amazonaws.com" },
    "Action": "sts:AssumeRole" } ] }
JSON

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

cat > sfn-perms.json <<JSON
{ "Version": "2012-10-17", "Statement": [
  { "Effect": "Allow", "Action": "lambda:InvokeFunction",
    "Resource": [
      "arn:aws:lambda:${AWS_REGION}:${ACCT}:function:sf-lab-worker",
      "arn:aws:lambda:${AWS_REGION}:${ACCT}:function:sf-lab-fail-handler" ] } ] }
JSON

aws iam put-role-policy --role-name sf-lab-sfn-role \
  --policy-name sf-lab-invoke --policy-document file://sfn-perms.json

Step 4 — Author the ASL definition

Save this as definition.json. It is the full graph: Task (Retry + Catch) → Choice (with Default) → Map (per-item Task) → Parallel (two branches) → Succeed, with a fail-handler path.

{
  "Comment": "Order pipeline lab: validate -> route -> fan-out -> parallel -> handle failure",
  "StartAt": "ValidateOrder",
  "States": {
    "ValidateOrder": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": { "FunctionName": "sf-lab-worker", "Payload.$": "$" },
      "ResultSelector": { "body.$": "$.Payload" },
      "ResultPath": "$.validation",
      "Retry": [
        { "ErrorEquals": ["Lambda.TooManyRequestsException", "States.TaskFailed"],
          "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2.0,
          "MaxDelaySeconds": 20, "JitterStrategy": "FULL" }
      ],
      "Catch": [ { "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "HandleFailure" } ],
      "Next": "RouteByTier"
    },
    "RouteByTier": {
      "Type": "Choice",
      "Choices": [
        { "Variable": "$.validation.body.tier", "IsPresent": true, "Next": "FanOutItems" }
      ],
      "Default": "FanOutItems"
    },
    "FanOutItems": {
      "Type": "Map",
      "ItemsPath": "$.validation.body.items",
      "MaxConcurrency": 5,
      "ItemProcessor": {
        "ProcessorConfig": { "Mode": "INLINE" },
        "StartAt": "ProcessItem",
        "States": {
          "ProcessItem": {
            "Type": "Task",
            "Resource": "arn:aws:states:::lambda:invoke",
            "Parameters": { "FunctionName": "sf-lab-worker", "Payload.$": "$" },
            "Retry": [ { "ErrorEquals": ["States.TaskFailed"], "IntervalSeconds": 1, "MaxAttempts": 2, "BackoffRate": 2.0 } ],
            "End": true
          }
        }
      },
      "ResultPath": "$.processed",
      "Catch": [ { "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "HandleFailure" } ],
      "Next": "EnrichParallel"
    },
    "EnrichParallel": {
      "Type": "Parallel",
      "Branches": [
        { "StartAt": "FraudCheck", "States": {
          "FraudCheck": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke",
            "Parameters": { "FunctionName": "sf-lab-worker", "Payload": { "step": "fraud" } }, "End": true } } },
        { "StartAt": "InventoryCheck", "States": {
          "InventoryCheck": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke",
            "Parameters": { "FunctionName": "sf-lab-worker", "Payload": { "step": "inventory" } }, "End": true } } }
      ],
      "ResultPath": "$.enrichment",
      "Catch": [ { "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "HandleFailure" } ],
      "Next": "Succeeded"
    },
    "Succeeded": { "Type": "Succeed" },
    "HandleFailure": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": { "FunctionName": "sf-lab-fail-handler", "Payload.$": "$" },
      "Next": "FailState"
    },
    "FailState": { "Type": "Fail", "Error": "OrderProcessingFailed", "Cause": "See $.error in the last input" }
  }
}

Step 5 — Create the state machine

SFN_ROLE=arn:aws:iam::${ACCT}:role/sf-lab-sfn-role
sleep 10

aws stepfunctions create-state-machine \
  --name sf-lab-order-pipeline \
  --type STANDARD \
  --role-arn $SFN_ROLE \
  --definition file://definition.json

Expected output — note the ARN:

{
  "stateMachineArn": "arn:aws:states:us-east-1:111122223333:stateMachine:sf-lab-order-pipeline",
  "creationDate": "2026-07-14T10:00:00.000000+00:00"
}
export SM_ARN=arn:aws:states:${AWS_REGION}:${ACCT}:stateMachine:sf-lab-order-pipeline

Step 6 — Run the success path

aws stepfunctions start-execution --state-machine-arn $SM_ARN \
  --name ok-$(date +%s) \
  --input '{"orderId":"A-1001","tier":"premium","items":[{"sku":"X1"},{"sku":"X2"},{"sku":"X3"}]}'

Grab the executionArn from the output, then poll status:

export EXE_OK=<paste executionArn>
aws stepfunctions describe-execution --execution-arn $EXE_OK \
  --query '{status:status, output:output}'

Expected: "status": "SUCCEEDED", with the output carrying validation, processed (an array of three processed items), and enrichment (the two parallel results).

Step 7 — Run the failure path and read the history

Send "fail": true so the first Task throws, retries three times with jitter, then Catch routes to the fail-handler and FailState.

aws stepfunctions start-execution --state-machine-arn $SM_ARN \
  --name bad-$(date +%s) \
  --input '{"orderId":"A-1002","fail":true,"items":[{"sku":"Y1"}]}'

export EXE_BAD=<paste executionArn>
aws stepfunctions describe-execution --execution-arn $EXE_BAD --query 'status'
# -> "FAILED"

# The history is the payoff — see the retries, the catch, and the terminal fail:
aws stepfunctions get-execution-history --execution-arn $EXE_BAD \
  --query 'events[].type' --output text

You will see a sequence including TaskStateEntered, several LambdaFunctionFailed / TaskFailed entries (the retries), then a TaskStateEntered for HandleFailure, and finally ExecutionFailed. That trail — every retry, the exact catch, the terminal error — is what you get for free with Standard and never had with hand-rolled Lambda chaining.

Step 8 — The Terraform equivalent

For real work, define the machine as code. aws_sfn_state_machine takes the ASL in definition; templatefile injects the function ARNs so you don’t hardcode account IDs.

resource "aws_iam_role" "sfn" {
  name = "sf-lab-sfn-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{ Effect = "Allow", Principal = { Service = "states.amazonaws.com" }, Action = "sts:AssumeRole" }]
  })
}

resource "aws_iam_role_policy" "sfn_invoke" {
  name = "sf-lab-invoke"
  role = aws_iam_role.sfn.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "lambda:InvokeFunction"
      Resource = [aws_lambda_function.worker.arn, aws_lambda_function.failh.arn]
    }]
  })
}

resource "aws_cloudwatch_log_group" "sfn" {
  name              = "/aws/vendedlogs/states/sf-lab-order-pipeline"
  retention_in_days = 14
}

resource "aws_sfn_state_machine" "pipeline" {
  name     = "sf-lab-order-pipeline"
  role_arn = aws_iam_role.sfn.arn
  type     = "STANDARD"

  definition = templatefile("${path.module}/definition.json.tftpl", {
    worker_arn = aws_lambda_function.worker.arn
    failh_arn  = aws_lambda_function.failh.arn
  })

  logging_configuration {
    log_destination        = "${aws_cloudwatch_log_group.sfn.arn}:*"
    include_execution_data = true
    level                  = "ERROR"
  }

  tracing_configuration { enabled = true }
}

In the .tftpl, reference ${worker_arn} and ${failh_arn} where the JSON had FunctionName. Switching the whole machine to Express is a one-line change: type = "EXPRESS" (and remember to make Tasks idempotent).

Step 9 — Teardown ⚠️

aws stepfunctions delete-state-machine --state-machine-arn $SM_ARN
aws lambda delete-function --function-name sf-lab-worker
aws lambda delete-function --function-name sf-lab-fail-handler
aws iam delete-role-policy --role-name sf-lab-sfn-role --policy-name sf-lab-invoke
aws iam delete-role --role-name sf-lab-sfn-role
aws iam detach-role-policy --role-name sf-lab-lambda-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name sf-lab-lambda-role
Lab input Path exercised Expected status
{"tier":"premium","items":[…3…]} Task → Choice → Map(3) → Parallel → Succeed SUCCEEDED
{"fail":true,"items":[…1…]} Task retries → Catch → HandleFailure → Fail FAILED
{"items":[]} (empty list) Map over 0 items (no-op) → Parallel → Succeed SUCCEEDED
{} (no items) ValidateOrder ok; Map ItemsPath missing → catch FAILED (States.Runtime)

Common mistakes & troubleshooting

The execution history is your primary tool: open the failed run, find the state highlighted in red, read its input and the error/cause. Ninety percent of diagnosis is “which state, what input, what error name.” The playbook below maps the failure classes to the confirm step and the fix.

# Symptom Root cause Confirm (exact command / console path) Fix
1 ExecutionFailed with States.TaskFailed Lambda/integration threw and nothing caught it History → the red Task’s TaskFailed event shows error/cause Add Catch on States.ALL (and specific errors) routing to a handler; add matching Retry for transient ones
2 States.Runtime, execution dies immediately, no retry Unrecoverable: bad ResultPath, missing field a path referenced, intrinsic error History event names States.Runtime with the path; check the state’s input JSON Fix the path; guard with IsPresent in a Choice first; States.Runtime is not retriable — Catch it instead
3 States.Permissions / AccessDeniedException Execution role lacks permission for that integration History TaskFailed cause names the iam action; CloudTrail AccessDenied Add the exact action on the exact resource ARN to the state machine role (not the Lambda role)
4 .sync task fails with an EventBridge/rule permission error Role can’t create the managed rule .sync needs Cause mentions events:PutRule/PutTargets/DescribeRule Grant the role the managed-rule permissions plus the job’s Describe*/Stop* actions
5 Execution stuck RUNNING for hours at one Task .waitForTaskToken — the callback never arrived History shows TaskScheduled/TaskStarted, no TaskSucceeded; list-executions --status-filter RUNNING Ensure the worker calls SendTaskSuccess/SendTaskFailure with $$.Task.Token; set HeartbeatSeconds + TimeoutSeconds
6 Downstream service throttles under a Map MaxConcurrency too high — fan-out floods it Downstream ProvisionedThroughputExceeded/429 spikes while Map runs Lower MaxConcurrency; batch with ItemBatcher; use Distributed Map with Express children
7 States.DataLimitExceeded A state’s input/output/payload exceeded 256 KB History event names States.DataLimitExceeded at the state Offload the blob to S3, pass a pointer; trim with ResultSelector/OutputPath
8 States.NoChoiceMatched A Choice matched no rule and had no Default History at the Choice state Add a Default; add IsPresent/type checks before value comparisons
9 Fail-handler gets the error but not the original order Catch ResultPath defaulted to $, overwriting input Handler’s input is just {Error, Cause} Set ResultPath: "$.error" on the Catch to preserve input + error
10 Express step ran twice (duplicate side effect) Express is at-least-once; internal retry re-ran it Two writes/logs for one input; CloudWatch Logs Make the Task idempotent (idempotency key / conditional write); use Standard for exactly-once
11 Huge Standard run fails around 25,000 events Execution history limit exceeded History truncates near 25k events; big inline Map/loop Move fan-out to Distributed Map (child histories); split into nested workflows; use Express
12 ExecutionThrottled / StartExecution 400s Over the account start-rate / open-execution quota AWS/States ExecutionThrottled metric; ThrottlingException Request a quota increase; add backoff on the caller; use Express for very high volume
13 Lambda .$ field arrives as literal text $.x Forgot the .$ suffix on the key Task input shows the literal path string Rename the key to end in .$ so the value is evaluated as a path
14 Parallel state fails though one branch “worked” Any branch failing fails the whole Parallel (States.BranchFailed) History shows one branch Failed Add Catch/Retry inside the branch, or on the Parallel state

An error-name quick reference for the confirm step:

Error name It means First move
States.TaskFailed The task reported failure Read the cause; add Retry/Catch
States.Runtime Unrecoverable data/path error Fix the path; do not retry
States.Timeout Task exceeded its timeout/heartbeat Raise TimeoutSeconds or fix the hung call
States.Permissions Role missing a permission Add least-privilege action to the SM role
States.DataLimitExceeded Payload > 256 KB Offload to S3, pass a pointer
States.NoChoiceMatched Choice fell through Add Default
States.BranchFailed A Parallel branch failed Catch inside the branch
States.ExceedToleratedFailureThreshold Distributed Map over tolerance Fix items or raise tolerance
States.HeartbeatTimeout No heartbeat within window Send heartbeats; raise HeartbeatSeconds

Finally, the limits that shape design decisions:

Quota / limit Value Consequence when hit
State/input/output payload 256 KB (262,144 bytes) States.DataLimitExceeded
Standard execution history 25,000 events Execution fails; use Distributed Map/nesting
Standard max duration 1 year Execution times out
Express max duration 5 minutes Execution fails
State machine definition size 1 MB create-state-machine rejected
Inline Map concurrency ~40 Iterations queue
Distributed Map concurrency 10,000 child executions Cap on parallelism
Open executions per account/region 1,000,000 (soft) New starts throttle
Task token lifetime Up to execution max (1 year) Stuck task if never sent

Best practices

Security notes

The execution role is the security boundary: Step Functions assumes it to make every integration call, so its permissions define the blast radius. Scope each statement to the exact action and the exact resource ARN — lambda:InvokeFunction on two named function ARNs, dynamodb:PutItem on one table, not wildcards. For .sync integrations, remember the role also needs the managed-rule permissions (events:PutRule, events:PutTargets, events:DescribeRule) and the job’s describe/stop actions; scope those too. Use Credentials on a Task to assume a different role for cross-account calls rather than widening the machine’s own role.

Guard the data plane. Execution input and output, and CloudWatch log data, can carry PII — set includeExecutionData: false (or redact) when logging sensitive flows, and encrypt the CloudWatch log group with a KMS key. Step Functions encrypts execution state at rest; you can bring a customer-managed KMS key for the state machine so execution data and the definition are encrypted under a key you control and audit. Control who can start executions with states:StartExecution on the specific state machine ARN — a public API in front of a sync Express workflow is an unauthenticated path straight into your orchestration if you don’t gate it. Prefer resource policies and VPC endpoints (com.amazonaws.<region>.states) so calls to the Step Functions API stay on the AWS network. Finally, treat task tokens as secrets: a token is a bearer credential to complete a step, so deliver it over encrypted channels (SNS/SQS with encryption, not a log line) and expire it with TimeoutSeconds.

Cost & sizing

The two workflow types bill on completely different models — get this wrong and a chatty Standard flow can cost 10x an equivalent Express one (or vice versa for long, sparse flows).

Cost driver Standard Express
Unit billed State transitions Requests + duration (GB-second)
Representative rate (us-east-1) ~$0.025 per 1,000 transitions ~$1.00 per 1M requests + ~$0.00001667/GB-s
Free tier 4,000 transitions/month (perpetual) None specific to Express
Waits/idle time Free (no compute) Billed by duration
What makes it expensive Many steps × many executions Long duration × high memory
Scenario Type Rough monthly cost (illustrative)
100k orders, 8 transitions each (800k) Standard ~$20 (₹1,700) after free tier
10M short events, 100 ms, 64 MB Express requests ~$10 + tiny duration ≈ ₹1,000–₹1,500
1M-item nightly Distributed Map (Express children) Express children + Standard parent dominated by child duration; batch to cut it
A workflow that waits 3 days then does 2 steps Standard ~2 transitions — effectively free

Right-sizing rules: count your transitions. A Standard workflow’s cost is executions × transitions-per-execution × rate, so collapsing four trivial Pass/Choice steps into one, or moving a hot inner loop to a nested Express child, directly cuts the bill. For Express, cost is requests + (memory × duration), so trimming duration (don’t Wait in Express — it’s billed) and right-sizing memory matter. The classic optimization is the hybrid: Standard parent for the auditable, long, low-volume outer process; Express (or Distributed Map with Express children) for the high-volume inner work. Numbers above are illustrative us-east-1 figures at ~₹83/USD — always confirm current regional pricing before you commit a design.

Interview & exam questions

Q1. When would you choose Express over Standard, and what do you give up? Express suits short (≤5 min), high-throughput, idempotent work — event/stream processing, sync API backends — and is far cheaper at high volume. You give up exactly-once (Express is at-least-once), the durable execution history (you get only CloudWatch Logs), the 1-year duration, and redrive. (DVA-C02, SAA-C03)

Q2. Explain the three service-integration patterns. Request-response returns as soon as the API call responds. .sync (run-a-job) holds the Task until the underlying job (ECS, Glue, Batch, nested SM) finishes. .waitForTaskToken pauses the Task until an external caller invokes SendTaskSuccess/SendTaskFailure with the injected task token — the basis of human-in-the-loop. (DVA-C02, SAP)

Q3. A Task fails once on a throttling error and the whole execution dies. Fix? Add a Retry block on the state listing the retriable error (e.g. Lambda.TooManyRequestsException) with IntervalSeconds, BackoffRate, and JitterStrategy: FULL; add a Catch on States.ALL for the non-transient case routing to a handler. (DVA-C02)

Q4. Inline Map vs Distributed Map? Inline runs iterations inside the parent execution/history, tops out around 40 concurrent, and is bounded by the 256 KB state limit. Distributed Map runs each iteration (or batch) as a child execution, reads up to millions of items from S3, scales to 10,000 concurrent children, supports ItemBatcher/ResultWriter/failure tolerance, and doesn’t bloat the parent history. Use Distributed for large-scale/S3 fan-out. (SAP, DVA-C02)

Q5. What does exactly-once vs at-least-once mean for your code? Standard’s exactly-once means each transition happens once — side effects are safe. Express’s at-least-once means a step can re-run, so every side-effecting Task must be idempotent (idempotency key or conditional write). (DVA-C02)

Q6. An execution is stuck RUNNING for hours. What’s the likely cause and how do you confirm? A .waitForTaskToken Task whose callback never arrived. Confirm in the execution history: TaskScheduled/TaskStarted with no TaskSucceeded. Fix by ensuring the worker sends the token and by setting HeartbeatSeconds/TimeoutSeconds. (SAP)

Q7. Where do IAM permissions for integrations live, and what’s special about .sync? On the state machine execution role, not the Lambda’s role. .sync integrations additionally need permission to manage the EventBridge rule Step Functions uses to detect job completion (events:PutRule/PutTargets/DescribeRule) plus the job’s describe/stop actions. (SCS, DVA-C02)

Q8. How do you pass a payload larger than 256 KB between states? You don’t — the 256 KB state limit is hard. Store the payload in S3 and pass the object key/pointer between states, or trim with ResultSelector/OutputPath. Exceeding it throws States.DataLimitExceeded. (DVA-C02)

Q9. Walk through the JSONPath I/O pipeline. In order: InputPath selects a subset of input; Parameters builds the task payload; the task runs; ResultSelector reshapes the raw result; ResultPath places it into the input JSON; OutputPath selects the final output. (DVA-C02)

Q10. What did JSONata mode add? An alternative query language (QueryLanguage: JSONata) using Arguments/Output instead of the five-field pipeline, full JSONata expressions in {% %}, and variables via Assign ($var) so you can carry a value across states without threading it through every ResultPath. (DVA-C02)

Q11. How do you observe Standard vs Express? Standard has a full, durable execution history in the console/API plus optional CloudWatch Logs and X-Ray. Express has no history API — you must enable CloudWatch Logs (levels ALL/ERROR/FATAL/OFF) and X-Ray to see anything. Both emit AWS/States metrics. (SOA-C02)

Q12. How would you fan out over every object in an S3 bucket and aggregate results? A Distributed Map with an ItemReader over the bucket, ItemBatcher to group objects per child, Express children for throughput, a ToleratedFailurePercentage, and a ResultWriter to write aggregated results back to S3. (SAP)

Quick check

  1. Which workflow type gives exactly-once execution and a full execution history?
  2. What happens to a Choice state when no rule matches and there’s no Default?
  3. Which ARN suffix makes a Task wait for an external callback token?
  4. What’s the maximum size of a state’s input/output payload?
  5. On a Catch, what should you set so the fail-handler keeps the original input?

Answers

  1. Standard. Express is at-least-once with logs-only visibility.
  2. It fails with States.NoChoiceMatched — always set a Default.
  3. .waitForTaskToken — the caller completes it with SendTaskSuccess/SendTaskFailure.
  4. 256 KB (262,144 bytes); larger payloads throw States.DataLimitExceeded — offload to S3.
  5. ResultPath: "$.error" — the default $ overwrites the entire input with the error object.

Glossary

Term Definition
State machine The ASL JSON document defining a workflow graph
Execution One run of a state machine against one input, with its own ARN and history
Amazon States Language (ASL) The JSON DSL used to define state machines
State One node in the graph — a Task, Choice, Parallel, Map, Wait, Pass, Succeed, or Fail
Task A state that calls a service, Lambda, or activity to do work
Standard workflow Exactly-once, up to 1 year, per-transition pricing, full history
Express workflow At-least-once, up to 5 min, per-request+duration pricing, logs-only
Retry Per-state rules to re-attempt on matching errors with backoff and jitter
Catch Per-state rules routing matching errors to a fallback state
Task token A callback credential injected for .waitForTaskToken, completed via SendTaskSuccess
.sync (run-a-job) Integration pattern where the Task waits for an underlying job to finish
Distributed Map Map mode that runs iterations as child executions over up to millions of S3 items
ResultPath I/O field controlling where a task’s result is placed in the state JSON
Context object ($$) Runtime metadata: execution id, state name, task token, map index
Execution role The IAM role Step Functions assumes to call integrated services

Next steps

AWSStep FunctionsAmazon States LanguageOrchestrationServerlessLambdaDistributed MapSaga
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading