Step Functions Input and Output Explained: InputPath, Parameters, ResultSelector, ResultPath and OutputPath, the Lambda Payload Wrapper, JSONata, Variables and the 256 KiB Limit
In AWS Step Functions, each state receives JSON input and passes JSON output to the next state, and five optional fields decide what goes where when you use the classic JSONPath language: InputPath picks part of the input, Parameters builds what the task receives, ResultSelector trims the task's result, ResultPath decides where the result goes in the input, and OutputPath picks what moves on. The one that causes most "where did my data go?" moments is ResultPath: its default, $, replaces the state's input with the task result, so set "ResultPath": "$.result" to keep the input and add the result beside it, or null to pass the input through untouched. Since late 2024 there is a simpler option: set "QueryLanguage": "JSONata" and the five fields become two, Arguments and Output, with variables (Assign) to keep values for later states. Every state's input and output must stay under 256 KiB. Below: the processing order with one worked example, each field's job, the Lambda Payload wrapper, the JSONata version, variables, the context object, the errors you will meet, and the size limit.
Jake's order workflow had three steps: check stock, charge the card, send the receipt. The receipt step failed every time with "The JSONPath '$.email' specified for the field 'to.$' could not be found in the input." Jake opened the execution and stared at the receipt step's input: it was just {"chargeId": "ch_123", "status": "succeeded"}. "Where is the customer?" he asked. "The order? The email?" Ethan scrolled up one step. "Gone. The charge step returned its result, and by default the result replaces everything that came in. You have been passing the receipt the bank's answer and nothing else."
New to Step Functions? Our plain-English Step Functions guide explains states, executions and Standard vs Express workflows. This page assumes you have a state machine and want the data to arrive where you expect.
The processing order, with one worked example
Where does the data come from in the first place? The execution input is whatever started the workflow: the JSON you pass to StartExecution, the event from an EventBridge rule, or the body of an API Gateway request. That JSON becomes the first state's input, and each state's output becomes the next state's input from there on.
With JSONPath, the default query language, a Task state processes data in a fixed order. Learn this order once and almost every input/output puzzle solves itself:
- InputPath selects part of the state's input (default
$, all of it). - Parameters builds the JSON the task actually receives, mixing fixed values with values picked from the input.
- The task runs and returns a result.
- ResultSelector builds a smaller object from the task's result.
- ResultPath decides where that result goes: replace the input, sit inside it, or be thrown away.
- OutputPath selects what part of the combined JSON becomes the state's output, which is the next state's input.
Here is Jake's charge step with all five fields, so you can watch the data move:
// State input
{ "orderId": "A-17", "customer": { "email": "jake@example.com" },
"payment": { "token": "tok_9", "amount": 4200 }, "debug": { "trace": "..." } }
"Charge card": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"InputPath": "$",
"Parameters": {
"FunctionName": "charge-card",
"Payload": { "token.$": "$.payment.token", "amount.$": "$.payment.amount", "currency": "USD" }
},
"ResultSelector": { "chargeId.$": "$.Payload.chargeId", "status.$": "$.Payload.status" },
"ResultPath": "$.charge",
"OutputPath": "$['orderId','customer','charge']",
"Next": "Send receipt"
}
// State output (the receipt step's input)
{ "orderId": "A-17", "customer": { "email": "jake@example.com" },
"charge": { "chargeId": "ch_123", "status": "succeeded" } }
Read it top to bottom: the function received only the token, amount and a fixed currency; its bulky response was cut down to two fields; those two fields were placed under charge without touching the order; and the debug block was dropped on the way out. Jake's original state had none of these fields, so the default ResultPath of $ replaced the whole input with the raw Lambda response.
What each field does, and when to use it
| Field | Works on | Default | Use it to |
|---|---|---|---|
| InputPath | State input | $ | Hand the task one branch of a large input, such as $.payment |
| Parameters | Output of InputPath | pass through | Build the exact request: fixed values plus "key.$" paths |
| ResultSelector | Task result | whole result | Keep only the fields you need from a large API response |
| ResultPath | Original input + result | $ (replace) | Keep the input and add the result, or discard the result |
| OutputPath | Combined JSON | $ | Pass only part of it to the next state |
Two syntax rules catch everyone once. First, inside Parameters, ResultSelector and Assign, a key that should take its value from a path must end in .$: "amount.$": "$.payment.amount". Without the .$, the task receives the literal text "$.payment.amount". Second, InputPath, ResultPath and OutputPath take a path string directly, with no .$ anywhere.
Not every state type has every field. Pass states have Parameters and Result but no task; Choice and Wait states have InputPath and OutputPath only; Succeed has InputPath and OutputPath; Fail has none.
The Pass state is your test bench. It runs no task, so it is the cheapest way to reshape data or inject fixed values. Put "Result": {"currency": "USD"} with "ResultPath": "$.defaults" on a Pass state and every later state sees $.defaults.currency. Many teams also use a Pass state as a placeholder while building, feeding fake results to the states after it until the real task is ready.
ResultPath vs OutputPath: the difference in one picture
These two are confused more than any other pair, because both seem to "decide the output." They work on different things:
- ResultPath answers: where does the task's result go? It combines the result with the state's original input.
- OutputPath answers: what part of that combined JSON moves on? It runs after ResultPath and can only select, never add.
With input {"order": "A-17", "debug": "..."} and a task result of {"ok": true}, setting "ResultPath": "$.result" produces {"order": "A-17", "debug": "...", "result": {"ok": true}}. Adding "OutputPath": "$.result" then passes on only {"ok": true}, which is the same as having left ResultPath at its default. Use ResultPath to keep things and OutputPath to drop things, and you will rarely need both on the same state.
ResultPath: keep the input, replace it or throw the result away
ResultPath is the field worth memorizing, because its default surprises almost everyone:
| ResultPath | Input {"order": "A-17"}, result {"ok": true}, output is | When to use |
|---|---|---|
"$" (default) | {"ok": true} | The next state needs only the result |
"$.result" | {"order": "A-17", "result": {"ok": true}} | Most of the time: keep context, add the result |
null | {"order": "A-17"} | Fire-and-forget tasks such as sending a notification |
"$.order" (an existing key) | {"order": {"ok": true}} | Rarely; it overwrites that key |
The same field matters in Catch blocks. Without a ResultPath, a caught error replaces the input with the error object, so your error-handling state no longer knows which order failed. Add "ResultPath": "$.error" to the catcher and the handler receives the original input plus the error details:
"Catch": [{
"ErrorEquals": ["States.ALL"],
"ResultPath": "$.error",
"Next": "Notify failure"
}]
Ethan fixed Jake's workflow with one line, "ResultPath": "$.charge", and the receipt step found the email on the next run. Then he added the same line to every Catch, before Jake had a chance to discover why he needed it.
The Lambda Payload wrapper
The recommended way to call Lambda is the optimized integration, arn:aws:states:::lambda:invoke. It returns more than your function's return value: your data sits inside Payload, next to fields such as StatusCode, ExecutedVersion and HTTP metadata. That is why the next state often finds $.Payload.orderId instead of $.orderId.
Three ways to unwrap it:
- ResultSelector, the cleanest:
"ResultSelector": {"body.$": "$.Payload"}with"ResultPath": "$.body"keeps your input and adds just the function's answer. - OutputPath:
"OutputPath": "$.Payload"passes only the function's answer on, dropping the input as well. - JSONata:
"Output": "{% $states.result.Payload %}".
If you are new to the function side of this, our plain-English Lambda guide explains what the function returns and why. The older style, putting the function's ARN directly in Resource, returns the bare return value with no wrapper, which is why tutorials disagree. Both styles work; the optimized integration adds automatic retries for some Lambda service errors and is what Workflow Studio generates, so expect the wrapper.
The same idea applies to other AWS SDK integrations: a DynamoDB getItem returns {"Item": {"name": {"S": "Jake"}}} with type markers, and ResultSelector is where you turn that into {"name": "Jake"} before the rest of the workflow sees it.
The JSONata way: Arguments, Output and fewer surprises
Since late 2024, Step Functions supports JSONata, a query and transformation language, as an alternative to JSONPath. Set "QueryLanguage": "JSONata" at the top of the state machine, or on individual states to convert one at a time. The five fields above are replaced by two:
- Arguments: what the task receives (replaces InputPath and Parameters).
- Output: what the state passes on (replaces ResultSelector, ResultPath and OutputPath).
Expressions go inside {% %}, keys never need .$, and a reserved variable $states holds input (the state's original input), result (the task result), errorOutput (inside a Catch) and context. Jake's charge step becomes:
"Charge card": {
"Type": "Task",
"QueryLanguage": "JSONata",
"Resource": "arn:aws:states:::lambda:invoke",
"Arguments": {
"FunctionName": "charge-card",
"Payload": {
"token": "{% $states.input.payment.token %}",
"amount": "{% $states.input.payment.amount %}",
"currency": "USD"
}
},
"Output": "{% $merge([$states.input, {'charge': $states.result.Payload}]) %}",
"Next": "Send receipt"
}
What replaces ResultPath in JSONata? Nothing by that name. By default a JSONata Task's output is the task result, which matches JSONPath's replace behavior. To keep the input and add the result, build it in Output with $merge, as above, or save the result in a variable with Assign and leave the output alone. To pass the input through untouched, set "Output": "{% $states.input %}", the JSONata version of "ResultPath": null.
JSONata also does what JSONPath needed intrinsic functions or a Lambda for: arithmetic ({% $states.input.price * $states.input.qty %}), filtering arrays ({% $states.input.items[stock > 0] %}), string building, dates and merging objects. Choice states use a single Condition expression instead of comparison operators, and Map states take Items instead of ItemsPath. Step Functions adds functions such as $uuid(), $hash(), $partition() and $parse() for JSON strings. It implements JSONata 2.0.6, with one exception: $eval is not available, so use $parse to turn a JSON string into an object.
Here is a JSONata Choice and Map together, routing large orders to review and pricing each line item:
"Big order?": {
"Type": "Choice",
"Choices": [{ "Condition": "{% $states.input.payment.amount > 100000 %}", "Next": "Manual review" }],
"Default": "Price items"
},
"Price items": {
"Type": "Map",
"Items": "{% $states.input.items %}",
"ItemProcessor": {
"ProcessorConfig": { "Mode": "INLINE" },
"StartAt": "Line total",
"States": {
"Line total": {
"Type": "Pass",
"Output": { "sku": "{% $states.input.sku %}", "total": "{% $states.input.price * $states.input.qty %}" },
"End": true
}
}
},
"Next": "Charge card"
}
A Parallel state returns an array with one element per branch; "Output": "{% $merge($states.result) %}" folds those into a single object, which replaces the ResultSelector juggling JSONPath needed for the same job.
Should you switch? For new workflows, yes: the model is simpler and the console defaults to it. For existing ones, there is no deadline; JSONPath remains the default, and you can convert one state at a time when you touch it. You cannot mix the field sets inside one state: a JSONata state rejects Parameters and ResultPath, and a JSONPath state rejects Arguments and Output.
If a JSONata expression fails at run time, for example by reading a field that does not exist, the state fails with States.QueryEvaluationError, which you can catch and retry like any other error. Expressions must finish within one second, so very heavy transformations still belong in a Lambda function.
Intrinsic functions: small transformations without a Lambda
JSONPath states cannot do arithmetic or build strings on their own, so Step Functions adds intrinsic functions, which you can call in Parameters, ResultSelector and Assign on any key ending in .$. They are the JSONPath answer to "I just need to glue two values together":
| Function | Example | Result |
|---|---|---|
| States.Format | States.Format('Order {} for {}', $.orderId, $.customer.email) | "Order A-17 for jake@example.com" |
| States.StringToJson | States.StringToJson($.body) | A JSON string turned into an object, useful for API Gateway and SQS bodies |
| States.JsonToString | States.JsonToString($.order) | The object as a string, for a message body |
| States.MathAdd | States.MathAdd($.attempt, 1) | A counter for loops |
| States.Array, States.ArrayLength, States.ArrayContains, States.ArrayGetItem | States.ArrayLength($.items) | Build, measure, search and index arrays |
| States.ArrayPartition, States.ArrayRange, States.ArrayUnique | States.ArrayPartition($.ids, 25) | Batches of 25, handy for DynamoDB batch writes |
| States.JsonMerge | States.JsonMerge($.defaults, $.overrides, false) | Two objects merged; the last argument must be false (shallow merge) |
| States.StringSplit | States.StringSplit($.tags, ',') | "a,b,c" becomes ["a","b","c"] |
| States.UUID, States.Hash, States.Base64Encode / Decode, States.MathRandom | States.UUID() | IDs, hashes, encoding and random numbers |
Used together, they let you delete a surprising number of tiny "glue" Lambda functions from your account. A common one you can retire today is the function that only parses an SQS or API Gateway body arriving as a string:
"Parameters": {
"order.$": "States.StringToJson($.body)",
"requestId.$": "States.UUID()",
"label.$": "States.Format('{}-{}', $.source, $$.Execution.Name)"
}
Intrinsic functions exist only in JSONPath states. In JSONata states the same jobs are done with JSONata itself (string concatenation with &, arithmetic, $parse) and the Step Functions additions $uuid(), $hash(), $partition(), $range() and $random().
Map and Parallel states: input and output for many items
Map states add their own data fields, and they are where your workflow is most likely to hit trouble first once your data grows. In JSONPath, you work with:
- ItemsPath points at the array to loop over, for example
$.items. - ItemSelector builds each iteration's input. Without it, each iteration receives just its item; with it, you can add values from the outer input, such as
"orderId.$": "$.orderId", and the current item as"item.$": "$$.Map.Item.Value". Older definitions call this field Parameters. - MaxConcurrency limits how many iterations run at once; 0 means as many as possible. Set it when the items call a rate-limited API.
- ResultSelector, ResultPath and OutputPath then work on the Map's result, which is an array with one entry per item, in the original order.
"Price items": {
"Type": "Map",
"ItemsPath": "$.items",
"ItemSelector": { "orderId.$": "$.orderId", "item.$": "$$.Map.Item.Value" },
"MaxConcurrency": 10,
"ItemProcessor": { "ProcessorConfig": { "Mode": "INLINE" }, "StartAt": "Price one", "States": { ... } },
"ResultPath": "$.pricedItems",
"Next": "Charge card"
}
The output array is the most common size problem: 2,000 items returning 200 bytes each is already 400 KB, over the 256 KiB limit. Trim each iteration's output inside the processor, or switch to Distributed Map, which reads items with ItemReader (from a JSON or CSV file in S3, an S3 inventory or a list of objects), can group them with ItemBatcher, writes results back to S3 with ResultWriter, and tolerates a set share of failed items with ToleratedFailurePercentage. With ResultWriter, the Map's own output is just a pointer to the results, so the payload stays small however many items there are.
Parallel states give every branch the same input and return an array of branch outputs, in branch order. In JSONPath, a ResultSelector can give the first and second entries names such as stock and fraud using array index paths; in JSONata, $merge($states.result) folds them into one object. Either way, add a ResultPath so the original input survives alongside the branch results.
Converting a JSONPath workflow to JSONata, step by step
You have no deadline to convert, but when you are already editing your workflow, converting the states you touch is a good habit. The console's Workflow Studio can convert a state for you; if you do it by hand once, you learn the mapping for good:
- Add
"QueryLanguage": "JSONata"to one state, not the whole machine. States without the field keep using the top-level language, which defaults to JSONPath. - Merge InputPath and Parameters into Arguments.
"amount.$": "$.payment.amount"becomes"amount": "{% $states.input.payment.amount %}". Drop every.$. - Merge ResultSelector, ResultPath and OutputPath into Output. Replace mode is the default; keep-the-input becomes
$merge([$states.input, {...}]); pass-through becomes$states.input. - Replace intrinsic functions with JSONata: States.Format becomes string concatenation with
&, States.StringToJson becomes$parse, States.MathAdd becomes+, and States.UUID becomes$uuid(). - Rewrite Choice rules as a single Condition, and Map's ItemsPath as Items.
- Replace
$$with$states.contextfor execution data and task tokens. - Test the state with the same sample input before and after, and compare the outputs field by field.
Ethan converted Jake's workflow one state per pull request over two weeks. Nothing broke, and each change was small enough to review in a minute.
Testing data flow with the TestState API
The quickest way to see exactly what each field does to your data is TestState, available in the console as Test state and from the AWS CLI. With the DEBUG inspection level, it shows the data after every processing step:
aws stepfunctions test-state \ --definition file://charge-card-state.json \ --role-arn arn:aws:iam::111122223333:role/StepFunctionsTestRole \ --input file://sample-order.json \ --inspection-level DEBUG
The response includes the state's output and, under inspection data, the input after InputPath, after Parameters, the raw result, after ResultSelector and after ResultPath. Reading those five snapshots side by side turns a mysterious "field not found" into an obvious one. The role is needed when the state calls a real service, because TestState actually runs the task; a Pass state needs no AWS calls at all.
Make TestState part of code review: keep a sample input file next to each state machine, run the changed states against it, and paste the output into the pull request. Data-flow mistakes are far cheaper to catch there than in a production execution history.
Variables: stop passing data through every state
Before variables, data needed in step five had to ride along through steps two, three and four. Now any state except Succeed and Fail can save values with Assign, and any later state can read them as $name. Variables work with both JSONPath and JSONata:
// JSONata: save the customer's email when the workflow starts
"Store order": {
"Type": "Pass",
"QueryLanguage": "JSONata",
"Assign": { "email": "{% $states.input.customer.email %}", "orderId": "{% $states.input.orderId %}" },
"Next": "Charge card"
}
// ...three states later
"Arguments": { "to": "{% $email %}", "subject": "{% 'Receipt for ' & $orderId %}" }
// JSONPath style: in a Task state, $ in Assign is the task result
"Assign": { "chargeId.$": "$.Payload.chargeId", "startedAt.$": "$$.Execution.StartTime" }
The rules worth knowing:
- New values appear in the next state. Every expression in a state uses variable values as they were when the state started.
- Assign and Output run side by side. A transformation done in Assign is not visible to Output in the same state; repeat it if both need it.
- Map and Parallel have their own scope. Inner states can read outer variables but cannot reuse their names, and their own variables disappear when the Map or Parallel finishes, so return results through Output. A Catch on the Map or Parallel can assign outer variables. Distributed Map cannot read outer variables at all.
- Limits: 256 KiB per variable, 256 KiB for everything in one Assign, and 10 MiB of variables per execution.
- Not in ResultPath: JSONPath states cannot use a variable as a ResultPath.
The context object: execution ID, start time and task tokens
Besides the input, every state can read the context object, which describes the execution itself. In JSONPath it starts with $$; in JSONata it is $states.context.
| JSONPath | JSONata | Typical use |
|---|---|---|
$$.Execution.Id | $states.context.Execution.Id | Logging, idempotency keys |
$$.Execution.StartTime | $states.context.Execution.StartTime | Timestamps, deadlines |
$$.Execution.Input | $states.context.Execution.Input | Recover the original input after it was replaced |
$$.Task.Token | $states.context.Task.Token | Wait-for-callback tasks, such as human approval |
$$.Map.Item.Value | $states.context.Map.Item.Value | The current item inside a Map iteration |
The task token is what makes wait-for-callback work. A task whose resource ends in .waitForTaskToken sends the token somewhere, such as a queue, and the workflow pauses until something calls SendTaskSuccess or SendTaskFailure with that token:
"Wait for approval": {
"Type": "Task",
"Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken",
"Parameters": {
"QueueUrl": "https://sqs.us-east-1.amazonaws.com/111122223333/approvals",
"MessageBody": { "orderId.$": "$.orderId", "token.$": "$$.Task.Token" }
},
"ResultPath": "$.approval",
"Next": "Charge card"
}
Whatever JSON the approver's code passes to SendTaskSuccess becomes the task result, and ResultPath places it, here under $.approval. Set a HeartbeatSeconds or TimeoutSeconds so a lost message does not leave the execution waiting for a year. Our SQS guide covers the queue side.
$$.Execution.Input is the quiet rescue for Jake's original bug: even after a ResultPath mistake replaced the data, the execution's first input is still there. Fixing ResultPath is better, but it is good to know the original never disappears.
What a caught error looks like, and how to read it
When a Catch fires, the data it hands on is an error object with two fields: Error, the error name such as States.TaskFailed, States.Timeout or a custom name your function threw, and Cause, a description. With "ResultPath": "$.error", the handler receives:
{ "orderId": "A-17", "customer": { "email": "jake@example.com" },
"error": { "Error": "CardDeclined",
"Cause": "{\"errorMessage\":\"insufficient funds\",\"errorType\":\"CardDeclined\"}" } }
Notice that Cause is a string, even when it holds JSON, as it does for Lambda errors. To use the message in a notification, parse it first: "cause.$": "States.StringToJson($.error.Cause)" in JSONPath, or {% $parse($states.errorOutput.Cause) %} in JSONata, where the error is available as $states.errorOutput inside the Catch's Output and Assign.
Error names drive both Retry and Catch matching, so it pays to throw specific ones from your code. A function that raises a CardDeclined error lets the workflow catch that case and email the customer, while letting a generic timeout fall through to a retry. States.ALL matches everything and belongs last in the list, as the catch-all.
Two errors relate directly to data handling, and States.ALL catches neither of them. States.DataLimitExceeded (covered next) is a terminal error that you can only catch or retry by naming it explicitly in ErrorEquals. States.Runtime, raised when the workflow cannot process its own data, for example applying InputPath or OutputPath to a null payload, cannot be retried or caught at all and always fails the execution. That is why the execution history, not a Catch, is where you usually meet data-flow bugs.
Retry settings and data. A Retry block resends the same input, so retries fix transient problems, such as throttling or a brief timeout, but never a data problem. Its fields are IntervalSeconds (1 by default), MaxAttempts (3 by default), BackoffRate (2.0 by default), MaxDelaySeconds to cap the growing wait, and JitterStrategy set to FULL to spread retries out. Every retry is billed as a state transition, so retrying an error caused by a bad path costs money and changes nothing. Keep retries for errors such as Lambda.TooManyRequestsException, Lambda.ServiceException and States.Timeout, and catch data errors instead.
The 256 KiB limit and States.DataLimitExceeded
Every state's input and output, every task's input and result, and the execution's own input and output must fit in 256 KiB of UTF-8 text, in both Standard and Express workflows. Past that, the execution fails with States.DataLimitExceeded. You cannot raise this limit, so design around it:
- Pass references, not data. Have the task write large results to S3 and return only the bucket and key; the next task reads the object itself. Queues have their own size limits too, covered in our SQS message size guide.
- Trim early with ResultSelector or JSONata Output, so a 200 KB API response becomes the 2 KB you need.
- Use Distributed Map for big datasets: it reads items directly from S3 objects or inventories and runs child workflows, so the list never sits in the state payload.
- Remember variables count separately against their own limits, so moving data into a variable does not make it free.
Standard workflows also cap execution history at 25,000 events. A long loop of small states can hit that before any payload limit; start a new execution, or use a Map, when a workflow repeats thousands of times.
Seeing the data: execution history, logs and sensitive values
Every input and output you have read about so far is visible somewhere, and once you know where, you will debug in minutes instead of hours.
- Standard workflows keep a full execution history for 90 days after an execution closes. In the console, open the execution, select a state in the graph, and the Input and Output tabs show exactly what that state received and passed on. The events table below lists every step, including each retry.
- Express workflows keep no history of their own. Turn on CloudWatch Logs logging for the state machine; with the log level set to ALL and execution data included, each state's input and output are written to the log group.
- From the CLI,
aws stepfunctions describe-executionreturns the execution's input and final output, andget-execution-historyreturns every event with its data.
The flip side is that anything in the payload is stored and visible to anyone who can read the history or logs. Card numbers, passwords and personal data should never travel through state input at all: store them in Secrets Manager or a database and pass an ID, the same pattern as the 256 KiB rule. If logs must stay on, consider leaving execution data out of them and logging only the event types. Ethan's first review of Jake's workflow found the full card token sitting in every execution's input; it now passes a payment ID that only the charge function can resolve.
Starting an execution with input from the CLI looks like this, and the same JSON is what a test run in the console asks you to paste:
aws stepfunctions start-execution \
--state-machine-arn arn:aws:states:us-east-1:111122223333:stateMachine:orders \
--name order-A-17 \
--input '{"orderId":"A-17","customer":{"email":"jake@example.com"},"paymentId":"pay_77"}'
The execution name must be unique for 90 days within a Standard state machine, which makes it a handy idempotency key: name each execution after the order, and an accidental second start for the same order is rejected instead of charging the customer twice. Inside the workflow, $$.Execution.Name gives you the same value.
Errors by message
| Message | Cause | Fix |
|---|---|---|
| The JSONPath '$.email' specified for the field 'to.$' could not be found in the input | The field was replaced by an earlier ResultPath, or sits under Payload | Check the previous state's output in the execution view; fix ResultPath or the path |
| Task receives the text "$.payment.amount" | Key is missing the .$ suffix | "amount.$": "$.payment.amount" |
| States.DataLimitExceeded | Payload over 256 KiB | Store in S3, pass the key; trim results |
| States.QueryEvaluationError | JSONata expression failed or read a missing field | Test the state with sample input; guard optional fields |
| Validation error on save: field not supported | JSONPath fields in a JSONata state, or the reverse | Use Arguments/Output with JSONata, the five path fields with JSONPath |
| Error handler does not know which item failed | Catch without ResultPath replaced the input | "ResultPath": "$.error" in the Catch |
Next state gets {"Payload": ..., "StatusCode": 200} | Optimized Lambda integration wrapper | ResultSelector on $.Payload |
The fastest way to debug any of these is the Test state feature in the console (the TestState API behind it): paste an input, run one state, and see the output after each processing step without starting a full execution. For a running workflow, open the execution, select the state, and compare its input and output tabs.
Step Functions pricing, and how data handling affects it
Standard workflows cost $0.025 per 1,000 state transitions in US East, with 4,000 free transitions a month; every state entered counts, including Pass states and retries, so a ten-state workflow run 100,000 times is about $25. Express workflows are billed per request ($1.00 per million) plus duration and memory, which suits high-volume, short workflows. Input and output processing itself is free, which is one more reason to reshape data with ResultSelector or JSONata inside the workflow instead of adding a Lambda function, and its own invocation charge, just to rename fields. Our Step Functions overview compares Standard and Express in more detail.
Data handling touches the bill in two quieter ways. Variables let you delete Pass states that existed only to carry data forward, and each deleted state is one fewer transition per run. And a workflow that keeps failing on a missing field and retrying three times pays for every attempt, so fixing a ResultPath bug is also a cost fix. Jake's workflow, retrying the receipt step for a week before anyone looked, had quietly tripled that step's transitions.
The whole order workflow, state by state
Here is how Jake's finished workflow moves data from start to end, using every idea on this page. If you read it as a story, with your own workflow in mind, the fields stop being abstract: you will recognize each decision you have to make in your own states.
- Start. The execution is named after the order and receives
{"orderId", "customer", "items", "paymentId"}. Nothing sensitive is in it. - Store order (Pass). Assigns
$emailand$orderIdas variables, so later states can use them without the data riding along. Its output is its input, unchanged. - Check stock (Task, DynamoDB getItem inside a Map). For each item, ItemSelector adds the order ID to the item, the task reads the stock record, and ResultSelector turns DynamoDB's typed answer into
{"sku", "inStock"}. The Map's ResultPath puts the array under$.stock, keeping everything else. - All in stock? (Choice). Reads
$.stockand routes to a "back-order" branch if any item is out. Choice states only select a path; they never change data. - Big order? (Choice). Orders over a set amount go to a wait-for-callback task that sends the task token to an approvals queue; the approver's answer lands under
$.approval. - Charge card (Task, Lambda). Parameters send only the payment ID and amount; ResultSelector unwraps Payload to
{"chargeId", "status"}; ResultPath stores it under$.charge. A Retry handles Lambda throttling, and a Catch forCardDeclinedwith ResultPath$.errorgoes to a "notify customer" branch that still knows which order failed. - Send receipt (Task, email or notification service). Reads
$emailand$orderIdfrom variables and the charge ID from$.charge. ResultPath isnull, because the email service's response is not needed by anything after it. - Done (Succeed). OutputPath picks
$['orderId','charge']as the execution's output, so callers see a short, useful result instead of the whole payload.
Every state either keeps the input and adds to it, or deliberately discards something. Nothing is replaced by accident, the payload stays small, and the two values needed at the end travel as variables. Rewritten in JSONata, the same workflow has the same shape; only the field names change, to Arguments and Output.
Try this with your own state machine: open its definition, and for each Task write down where its result goes. If your answer for any state is "I am not sure," that state is using the default ResultPath, and you have found your next bug before your users do. If your workflow passes the same value through four states that never use it, you have found a variable waiting to be created. And if your execution output is the whole accumulated payload, your callers will thank you for an OutputPath at the end. You do not need to rewrite anything to get these wins; you only need to look at your workflow one state at a time.
When Ethan walked a new teammate through it, he asked one question at each state: "What comes in, what goes to the task, what comes back, where does it go, and what leaves?" Those five questions are the five JSONPath fields, in order, and they work just as well as a checklist for JSONata states.
For teams: conventions that prevent data bugs
- Default to
"ResultPath": "$.<stepName>"on every Task, so each step's result lands under its own name and nothing is silently replaced. - Always set ResultPath on Catch to
$.error. - Trim AWS SDK responses with ResultSelector right where they enter the workflow.
- Choose one query language per state machine for new work, ideally JSONata, and convert older states when you change them.
- Keep payloads small by design: anything that could grow, such as lists of records, goes to S3.
- Test states with real sample inputs in the TestState API as part of code review.
Teams that adopt the first two rules alone remove most of the "where did my data go?" tickets.
Step Functions input and output: frequently asked questions
What is the difference between InputPath, Parameters, ResultPath and OutputPath?
InputPath selects part of the input, Parameters builds the task's request, ResultPath places the result in the input, and OutputPath selects what passes to the next state.
Why does my Step Functions state lose its input?
ResultPath defaults to $, which replaces the input with the task result. Set ResultPath to a key such as $.result to keep both.
How do I pass input through to the output unchanged?
Set "ResultPath": null. The task still runs, but its result is discarded and the input passes on.
What does ResultSelector do?
It builds a new object from the task's result before ResultPath places it, so you keep only the fields you need.
Why is my Lambda result inside Payload?
The optimized lambda:invoke integration wraps the function's return value in Payload with metadata. Use "ResultSelector": {"body.$": "$.Payload"} or OutputPath $.Payload.
What does .$ mean in Step Functions?
A key ending in .$ tells Step Functions to evaluate the value as a path or intrinsic function instead of using it as literal text.
What is JSONata in Step Functions?
An alternative query language. With "QueryLanguage": "JSONata", states use Arguments and Output with {% %} expressions instead of the five JSONPath fields.
Should I use JSONata or JSONPath?
JSONata for new workflows, because it is simpler and more capable. JSONPath remains the default and works fine; convert states when you change them.
How do I use variables in Step Functions?
Add an Assign field to a state to save values, then read them in later states as $name. Variables work with both JSONPath and JSONata.
What is the Step Functions input and output size limit?
256 KiB of UTF-8 text for each state's input and output, task inputs and results, and execution input and output. It cannot be raised.
How do I fix States.DataLimitExceeded?
Store large data in S3 and pass the bucket and key, trim results with ResultSelector, or use Distributed Map for large item lists.
How do I get the execution ID inside a state?
Use $$.Execution.Id in JSONPath or $states.context.Execution.Id in JSONata.
How do I keep the input when catching an error?
Add "ResultPath": "$.error" to the Catch so the handler receives the original input plus the error.
Can Map iterations use variables from outside the Map?
Inline Map and Parallel states can read outer variables but keep their own scope. Distributed Map cannot read outer variables.
How do I test input and output processing?
Use Test state in the console or the TestState API with sample input to see the output of one state without running the workflow.
How much does Step Functions cost?
Standard workflows are $0.025 per 1,000 state transitions in US East, with 4,000 free a month. Express workflows charge per request plus duration.
What is the step input in Step Functions?
The JSON a state receives: the execution input for the first state, and the previous state's output for every state after it.
What does ResultPath null do in a Catch?
It discards the error and passes the original input to the handler. Use "ResultPath": "$.error" instead if the handler needs to know what went wrong.
How do I merge objects in JSONata?
Use $merge with an array of objects, for example {% $merge([$states.input, {'charge': $states.result.Payload}]) %}. Later objects win when keys overlap.
Which JSONata version does Step Functions use?
JSONata 2.0.6, with every built-in function except $eval, plus Step Functions additions such as $uuid, $hash, $partition, $range, $random and $parse.
What is the difference between ResultPath and OutputPath?
ResultPath decides where the task result is placed in the input. OutputPath runs afterward and selects which part of that combined JSON goes to the next state.
How do I run or test Step Functions states locally?
Call the TestState API from the AWS CLI or an SDK on your machine with your state definition and sample input. It runs one state and shows the processed output without deploying a full workflow.
How do I write a Choice state in JSONata?
Use a Condition with an expression, for example "Condition": "{% $states.input.amount > 100 %}", instead of Variable and comparison operators.
Can a JSONata state use ResultPath?
No. JSONata states use Arguments and Output only, and saving a JSONata state with JSONPath fields fails validation.
For readers preparing for an AWS certification
We have been covering AWS certifications alongside our fixes, so here is a short extra for readers studying for one. If you only came for the fix, you already have it; feel free to skip this part.
Exams that test this topic: Developer – Associate (DVA-C03 from December 1, 2026) • Solutions Architect – Associate (SAA-C03) • DevOps Engineer – Professional (DOP-C02).
Here are the exam-style questions this topic tends to raise, with the reasoning you need to answer them:
1. A Step Functions task must add its result to the state input instead of replacing it. Which field do you set?
ResultPath, for example "ResultPath": "$.result".
2. A workflow needs a human approval step that can take days. Which pattern fits?
A wait-for-callback task (.waitForTaskToken) that sends the task token out; the approver calls SendTaskSuccess or SendTaskFailure.
3. A workflow must process millions of objects listed in S3 without hitting payload limits. What do you use?
A Distributed Map state with ItemReader reading from S3 and ResultWriter writing results back to S3.
4. What is the maximum size of a state's input or output in Step Functions?
256 KiB. Larger data should be stored in S3 and passed by reference.
5. Which workflow type suits high-volume, short-lived event processing at lower cost?
Express workflows, billed per request and duration; Standard workflows suit long-running, auditable processes.
6. How do you keep the original input when a Catch handles an error?
Set ResultPath on the catcher, for example "$.error", so the error is added to the input.
7. A Lambda call in a workflow fails intermittently with throttling errors. What should you configure?
A Retry with exponential backoff (IntervalSeconds, BackoffRate, MaxAttempts) for Lambda.TooManyRequestsException.
Exam codes and dates change often. Our AWS certification changes page keeps track of what is current.
Jake's order workflow has grown to eleven states since then. Every Task has a ResultPath named after itself, every Catch keeps the input, and the receipt has found the customer's email every time.
📌 If you keep one line from this page
InputPath, Parameters, task, ResultSelector, ResultPath, OutputPath, in that order; ResultPath defaults to replacing your input, so set it to $.result, and for new workflows use JSONata's Arguments, Output and variables instead.
Lambda results live in Payload · keys need .$ · Catch with $.error · 256 KiB per payload · big data goes to S3.
Revision note. Written October 18, 2026. "Know where you are going, and you will know what to carry," as travelers say; a state machine is the same, and ResultPath is where you decide what to carry.