GitHub Actions JSON Output: Step Outputs, Dynamic Matrices, and fromJSON / toJSON
Last updated:
This guide is the output-side companion to our GitHub Actions JSON parent guide — it focuses on how a step or job emits JSON and how downstream steps and jobs consume it. The four mechanisms covered here are the $GITHUB_OUTPUT file (the replacement for the deprecated ::set-output:: workflow command), the EOF heredoc delimiter for multi-line JSON values, the fromJSON() and toJSON() expression functions, and the dynamic-matrix pattern that uses all of them together. If you arrived looking for how to read a JSON file checked into the repo, parse a webhook payload, or querygithub.event, the parent guide is the better starting point. Stay here for emitting JSON between steps, fanning out matrix builds, and passing structured data across job boundaries.
Building a dynamic matrix and not sure your JSON is valid? Paste the output of your generator script into Jsonic's JSON Validator — it catches the stray comma, unbalanced bracket, or unquoted key before the workflow expands to zero runs and skips silently.
Validate your matrix JSONStep outputs vs job outputs vs environment files: the three output mechanisms
GitHub Actions has three distinct places to put a value so a later piece of the workflow can read it, and they are not interchangeable. Picking the wrong one is the most common reason a workflow that "writes the output" still ends up with an empty downstream reference.
- Step outputs — written to the
$GITHUB_OUTPUTfile asname=valuelines. Visible only to later steps in the same job viasteps.<step-id>.outputs.<name>. The step must have anidfor downstream references to work. - Job outputs — declared in the job's
outputs:map and populated by referencing a step output. Visible to downstream jobs that list this job in theirneeds:array, vianeeds.<job-id>.outputs.<name>. A step output is not automatically a job output — you have to re-export it. - Environment file — written to
$GITHUB_ENVasNAME=valuelines. Becomes a real environment variable in every later step of the same job. Use this when a subprocess (a script, a build tool) needs to read the value viaprocess.envrather than through an expression.
Side-by-side: if a value will be consumed by another step as an expression, write it to $GITHUB_OUTPUT. If it will be consumed by a child process (a Node script, a CLI), write it to $GITHUB_ENV. If it needs to cross a job boundary, write it to both $GITHUB_OUTPUTand the job's outputs: map.
GITHUB_OUTPUT file format and multi-line JSON output
The $GITHUB_OUTPUT file is a plain text file whose path is exported into your shell at the start of every step. The runner reads it after the step completes and turns each name=value line into a step output. Two forms are supported: single-line key=value and the EOF heredoc delimiter pattern for multi-line values.
# Single-line form — value cannot contain literal newlines
- id: pkg
run: |
VERSION=$(jq -r .version package.json)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# Compact JSON via jq -c — fits the single-line format
- id: build
run: |
DATA=$(jq -c '{name: .name, version: .version, deps: (.dependencies | keys)}' package.json)
echo "data=$DATA" >> "$GITHUB_OUTPUT"
# Multi-line form — EOF heredoc delimiter
- id: report
run: |
{
echo 'report<<EOF'
jq . results.json
echo 'EOF'
} >> "$GITHUB_OUTPUT"The delimiter (EOF in the example) can be any string that does not appear in the value. For machine-generated JSON where you cannot predict the contents, a random delimiter is safer: DELIM=$(uuidgen), then use $DELIM in place of EOF. The runner reads from the line after the opening delimiter to the line before the closing delimiter and treats everything in between as the value, newlines included.
The deprecated ::set-output name=foo::bar workflow command (a ::-prefixed line printed to stdout) was removed in October 2022. Workflows that still use it will hit a deprecation warning followed by failure on current runners — migrate to the file-based form.
Dynamic matrix from JSON: building matrix.include programmatically
The dynamic-matrix pattern lets a workflow decide at runtime how many matrix runs to fan out and what variables each run sees. A setup job generates a JSON array, exposes it as a job output, and a downstream job parses it with fromJSON() in its strategy.matrix field. The downstream job is then expanded into one run per array element exactly as if the matrix had been written statically.
jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.gen.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: gen
run: |
# Build an array of {node, os} combos based on a file checked into the repo
MATRIX=$(jq -c '.targets' .github/build-targets.json)
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
build:
needs: setup
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.setup.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci && npm testThe shape of the array decides what the downstream job sees. For matrix.include, each element is an object whose keys become matrix variables for that run: [{"node":"20","os":"ubuntu-latest"}, {"node":"22","os":"macos-latest"}]. For a single-dimension matrix you can also write matrix: { node: ${{ fromJSON(...) }} } with a flat array like ["18", "20", "22"].
If the array is empty, the downstream job is skipped entirely with no runs and no error — useful as a guard (skip the build job when there is nothing to build) but a foot-gun when the empty result is unintended. Always log the generated JSON in the setup job so you can see exactly what was emitted when something looks wrong.
fromJSON() expression: when and how to use it
fromJSON() is a function inside the expression syntax (${{ ... }}) that parses a JSON string into a navigable value. Everything that comes out of a step output, an environment variable, a secret, or a workflow_dispatch input is a string. To index into it like an object, you have to parse it first.
# Step writes JSON to its output
- id: api
run: |
RESPONSE=$(curl -s https://api.example.com/build-info)
echo "data=$RESPONSE" >> "$GITHUB_OUTPUT"
# Later steps parse it
- name: Use parsed fields
if: ${{ fromJSON(steps.api.outputs.data).status == 'ready' }}
run: |
echo "Tag: ${{ fromJSON(steps.api.outputs.data).tag }}"
echo "SHA: ${{ fromJSON(steps.api.outputs.data).commit.sha }}"
# Workflow dispatch input as JSON
on:
workflow_dispatch:
inputs:
config:
description: 'JSON config blob'
type: string
jobs:
use-config:
runs-on: ubuntu-latest
steps:
- run: echo "Region: ${{ fromJSON(inputs.config).region }}"fromJSON() accepts both JSON strings and values that have already been parsed. Calling it on an already-parsed value is a no-op, so it is safe to wrap a value you are not sure about without breaking the second branch. This matters in reusable workflows where a caller might pass either a literal object or a stringified version of it.
Errors are surfaced at expression evaluation time. If the string is not valid JSON, the workflow fails with an expression error citing the line and column. Validate untrusted inputs (paste it into a validator, or run a quick jq empty step first) before letting fromJSON() see them.
toJSON() expression and embedding context as JSON
toJSON() is the inverse of fromJSON() — it serializes any expression value (a context, an object, an array, a step-outputs map) into a JSON string. The three most common uses are debugging, passing structured data through a string-only channel, and snapshotting context state.
# 1. Debug: log the entire event payload as pretty JSON
- name: Dump event
run: echo '${{ toJSON(github.event) }}' | jq .
# 2. Pass an object through a single-line input
- uses: ./.github/actions/deploy
with:
config: ${{ toJSON(needs.plan.outputs) }}
# 3. Capture all step outputs as a snapshot for a downstream job
- id: snapshot
run: |
cat <<'JSON' >> "$GITHUB_OUTPUT"
snapshot<<EOF
${{ toJSON(steps) }}
EOF
JSONThe output of toJSON() is pretty-printed (with newlines and indentation) for context values like github and steps. That makes it nice to read in logs but breaks the single-line $GITHUB_OUTPUT format — use the EOF heredoc form when writing the result to an output.
toJSON() and fromJSON() compose: fromJSON(toJSON(x)) round-trips to an equivalent value. The round-trip is occasionally useful as a forcing function — to make the expression engine re-evaluate a value that arrived as an opaque string but is structurally JSON, wrap it once and unwrap it once.
See also our JSON string escaping guide for the rules that toJSON() applies to special characters (double quotes, backslashes, control characters) when serializing.
Setting step outputs as JSON from inside scripts (bash, Python, Node)
When the JSON is generated by a script rather than a one-liner, the script needs to write to the $GITHUB_OUTPUTfile directly. The runner exports the variable into the step's environment, so any subprocess can append to it.
# Bash with jq — most idiomatic
- id: bash
run: |
RESULT=$(jq -nc \
--arg name "$BUILD_NAME" \
--arg sha "$GITHUB_SHA" \
'{name:$name, sha:$sha, ts:now}')
echo "result=$RESULT" >> "$GITHUB_OUTPUT"# Python — use json.dumps, never f-string concatenation
- id: py
run: python build-matrix.py
env:
INPUT_VERSIONS: '18,20,22'
# build-matrix.py
import json, os, sys
versions = os.environ['INPUT_VERSIONS'].split(',')
matrix = {'include': [{'node': v, 'os': 'ubuntu-latest'} for v in versions]}
with open(os.environ['GITHUB_OUTPUT'], 'a') as fh:
fh.write(f"matrix={json.dumps(matrix)}\n")# Node.js with @actions/core — the official toolkit
- id: node
uses: actions/github-script@v7
with:
script: |
const matrix = {
include: [
{ node: '20', os: 'ubuntu-latest' },
{ node: '22', os: 'macos-latest' },
],
}
core.setOutput('matrix', JSON.stringify(matrix))core.setOutput() from @actions/core (or its in-action wrapper in actions/github-script) handles the file write for you and applies the correct escaping for multi-line values. For Python or plain bash, you are writing the line yourself — pipe through jq -c or json.dumps so the value stays single-line, or use the EOF heredoc form. Never build JSON via shell string concatenation — quotes and backslashes in inputs will silently break the output.
workflow_dispatch inputs as JSON for reusable workflows
workflow_dispatch and workflow_call inputs are typed — the supported types are string, boolean, number, choice, and environment. There is no native object or json type. To pass structured data, declare a string input and parse it inside the workflow with fromJSON().
on:
workflow_dispatch:
inputs:
deploy_config:
description: 'JSON deploy config'
type: string
required: true
default: '{"env":"staging","region":"us-east-1","tag":"latest"}'
workflow_call:
inputs:
deploy_config:
type: string
required: true
jobs:
deploy:
runs-on: ubuntu-latest
env:
ENV: ${{ fromJSON(inputs.deploy_config).env }}
REGION: ${{ fromJSON(inputs.deploy_config).region }}
TAG: ${{ fromJSON(inputs.deploy_config).tag }}
steps:
- name: Validate input is real JSON
run: echo '${{ inputs.deploy_config }}' | jq empty
- run: ./deploy.sh "$ENV" "$REGION" "$TAG"The jq empty step is cheap insurance: it exits non-zero if the input is not valid JSON, failing the run with a clear error before downstream expressions raise an opaque expression failure. This matters most for workflow_dispatch runs triggered manually by humans, who may paste partial JSON or accidentally include surrounding quotes.
For reusable workflows that need many structured inputs, evaluate whether to pass one big JSON string or several typed inputs. Many small typed inputs give you better error messages and dashboard UI in the Run workflow dialog; one big JSON input is more concise but trades safety for compactness. The hybrid that works well in practice is to expose a few common knobs as typed inputs and a single overrides JSON input for everything else.
Parsing job/step status JSON in matrix downstream jobs
Downstream jobs that depend on a matrix-driven producer often need to know which matrix runs passed, which failed, and which were skipped. The producer's status is collapsed across the matrix — needs.<producer>.result is a single value (success, failure, or cancelled) representing the worst result, not a per-run breakdown.
To get per-run detail, the producer matrix runs each upload a small JSON artifact (their slice of the result) and a follow-up job downloads all of them, parses, and aggregates. The follow-up runs once, not per matrix element.
jobs:
test:
needs: setup
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.setup.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- id: run
continue-on-error: true
run: npm test -- --shard ${{ matrix.shard }}
- name: Record result
if: always()
run: |
jq -nc \
--arg shard "${{ matrix.shard }}" \
--arg status "${{ steps.run.outcome }}" \
'{shard:$shard, status:$status}' > "result-${{ matrix.shard }}.json"
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-result-${{ matrix.shard }}
path: result-${{ matrix.shard }}.json
aggregate:
needs: test
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: test-result-*
merge-multiple: true
- id: summary
run: |
SUMMARY=$(jq -cs 'group_by(.status) | map({(.[0].status): length}) | add' result-*.json)
echo "summary=$SUMMARY" >> "$GITHUB_OUTPUT"
- run: |
echo "## Test summary" >> "$GITHUB_STEP_SUMMARY"
echo '${{ steps.summary.outputs.summary }}' | jq . >> "$GITHUB_STEP_SUMMARY"Two pieces glue this together. continue-on-error: true on the test step lets the matrix run finish even when tests fail, so the result-recording step still executes. steps.<id>.outcomeis the per-step result before the continue-on-error rewrite — it is what you want to capture. The aggregate job's if: always() guarantees it runs even when the matrix had failures.
For broader CI patterns beyond GitHub Actions specifically, see our JSON in CI/CD generally guide. For deeper jq for JSON in workflows recipes, that guide covers the filter syntax used throughout this page.
Key terms
- $GITHUB_OUTPUT
- An environment variable holding the path to a file the runner reads after each step. Writing
name=valuelines (or the EOF heredoc form for multi-line) registers step outputs. Replaced the deprecated::set-output::workflow command in October 2022. - step output
- A named value written to
$GITHUB_OUTPUTby a step with anid. Readable by later steps in the same job viasteps.<id>.outputs.<name>. Always a string until parsed withfromJSON(). - job output
- A named value declared in a job's
outputs:map. Re-exports a step output for downstream jobs that include this job in theirneeds:list. Readable asneeds.<job-id>.outputs.<name>. - fromJSON()
- An expression function that parses a JSON string into a value the expression engine can index with dot or bracket notation. Required to navigate into structured step outputs, secrets, and inputs. Accepts already-parsed values as a no-op.
- toJSON()
- An expression function that serializes any context value (object, array, context map) into a JSON string. Used for debugging context state, passing structured data through string-only channels, and capturing snapshots.
- dynamic matrix
- A
strategy.matrixwhose values come fromfromJSON()on a previous job's output rather than being written literally in the YAML. Lets a workflow decide at runtime how many runs to fan out and what each run sees. - EOF heredoc delimiter
- The multi-line value pattern for
$GITHUB_OUTPUTand$GITHUB_ENV:name<<DELIM, value lines,DELIM. The runner reads everything between the opening and closing delimiter as a single value, newlines included.
Frequently asked questions
How do I set a GitHub Actions output to a JSON value?
Write to the $GITHUB_OUTPUT file with the key=value format, where the value is a compact JSON string. The simplest single-line form is: echo "result=$(jq -c . data.json)" >> "$GITHUB_OUTPUT" — jq -c collapses any pretty-printed JSON into one line so the key=value parser does not break on newlines. Give the step an id so later steps can reference it, then read the output with steps.<id>.outputs.result as a string, or wrap it in fromJSON() to parse it back into an object: ${{ fromJSON(steps.build.outputs.result).version }}. The older ::set-output:: workflow command was deprecated in October 2022 and removed; any tutorial that still shows it predates the file-based approach and will fail on current runners. For multi-line or human-readable JSON, use the EOF heredoc form described below.
What is fromJSON() in GitHub Actions expressions?
fromJSON() is a built-in expression function that parses a JSON string into a value that the expression engine can index with dot or bracket notation. Without it, everything coming out of a step output, an environment variable, or a secret is a string, and you cannot navigate into the object. With it, ${{ fromJSON(steps.api.outputs.body).user.email }} reads a field directly. fromJSON() also accepts already-parsed JSON values when one expression feeds another, so wrapping a value that is already an object is a no-op rather than an error. The two places you will use it most often are matrix construction (strategy.matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}) and conditional logic that needs to see structured data rather than a stringified blob. It is the inverse of toJSON().
How do I build a dynamic matrix from a previous job's output?
Use two jobs. The first (call it setup) runs a script that emits a JSON array — one element per fan-out target — and writes it to $GITHUB_OUTPUT. The job declares the output at the job level: outputs: { matrix: ${{ steps.gen.outputs.matrix }} }. The second job lists needs: [setup] so the output is available, then sets its strategy.matrix value to fromJSON(needs.setup.outputs.matrix). The downstream job is then expanded into one run per array element exactly as if you had written the matrix statically. Use matrix.include (array of objects with the variables you want each run to see) when the runs differ in more than one dimension, or matrix.<key> (flat array) when you only vary one. Always validate the JSON before commit — a typo in the generator script silently produces an empty matrix and the downstream job skips with no runs.
Why does my JSON output get truncated in $GITHUB_OUTPUT?
Two common causes. First, the $GITHUB_OUTPUT file uses key=value lines, so a literal newline inside the value terminates the entry — anything after the first newline is interpreted as a new key or as junk. Pretty-printed JSON breaks this format. Fix: pipe through jq -c to compact the JSON to a single line, or use the EOF heredoc delimiter pattern (name<<EOF, value lines, EOF) which tells the parser to read until the delimiter. Second, GitHub recommends keeping individual outputs under about 1 MB; very large payloads can be truncated or rejected by the runner. If you genuinely need to pass megabytes of JSON between steps or jobs, write it to a file and upload it as an artifact, then download in the consumer. Outputs are for small structured data, not blobs.
How do I pass JSON between jobs in a workflow?
Declare an outputs map at the job level (not just the step level). The job-level outputs block maps a name you choose to a step-output expression: outputs: { config: ${{ steps.build.outputs.config }} }. Downstream jobs that include the producer in their needs array can then read it as needs.<producer-id>.outputs.config. To consume the value as an object, wrap with fromJSON(): ${{ fromJSON(needs.build.outputs.config).region }}. One known limitation: a job that uses a matrix cannot directly expose per-matrix-run outputs to downstream jobs because the outputs map is collapsed across the matrix. Workarounds are to fan-in through an artifact (each matrix run uploads its slice, a follow-up job merges) or to compute the aggregate before the matrix runs and pass it forward as a single output.
What's the difference between fromJSON and toJSON?
fromJSON() parses a JSON string into an expression value you can index. toJSON() does the reverse — it serializes any context value (an object, an array, a step output map) into a JSON string. Use toJSON() when you want to log the full shape of a context for debugging (echo ${{ toJSON(github.event) }} | jq .), when you need to pass a structured value through a single-line string channel like a step input, or when you want to capture a context snapshot into $GITHUB_OUTPUT for a downstream job. Use fromJSON() on the receiving end to turn that string back into a navigable value. The two compose: fromJSON(toJSON(x)) round-trips to an equivalent value, and is occasionally useful to force the expression engine to re-evaluate a value as parsed JSON when it would otherwise treat it as opaque.
How do I escape a JSON string for use in a step output?
The safest approach is to never escape by hand. Pipe through jq -c — it produces compact, fully-escaped JSON suitable for a single-line key=value entry. If you must build JSON in shell, prefer here-strings into jq with --arg: jq -nc --arg name "$NAME" --arg msg "$MSG" '{name:$name, message:$msg}'. The --arg form treats inputs as strings and escapes them correctly regardless of quotes, backslashes, or newlines they contain. Avoid printf-style construction (printf '{"k":"%s"}' "$x") because any double quote, backslash, or newline in $x produces invalid JSON. For Python or Node, use json.dumps / JSON.stringify rather than string concatenation. Once the JSON string is built, write it to $GITHUB_OUTPUT directly — the runner does not perform any additional escaping on output values.
Can I use a JSON file in a workflow_dispatch input?
workflow_dispatch inputs are typed as string, boolean, number, choice, or environment — there is no native object or json type. To pass structured data, declare a string input and have the operator paste a JSON literal into it when triggering the workflow. Read it inside the workflow with fromJSON(): ${{ fromJSON(inputs.config).region }}. For reusable workflows called via workflow_call, the same pattern works — the calling workflow can pass a stringified JSON value as a string input. Validate the JSON before parsing if untrusted users can trigger the workflow: an invalid string makes fromJSON() raise an expression error that aborts the run. For complex inputs that exceed the manual-paste threshold, consider committing a config file to the repo and referencing it by a short input (the path, a tag, an environment name) rather than passing the whole JSON document through inputs.
Further reading and primary sources
- GitHub Docs — Workflow commands: setting an output parameter — Authoritative reference for $GITHUB_OUTPUT and the EOF heredoc delimiter syntax
- GitHub Docs — Expressions: fromJSON and toJSON — Built-in functions for parsing and serializing JSON inside workflow expressions
- GitHub Docs — Using a matrix for your jobs — Matrix strategy syntax including include, exclude, and the dynamic pattern via fromJSON
- actions/toolkit — @actions/core setOutput — Official Node.js toolkit used by JavaScript actions and actions/github-script
- GitHub Changelog — Deprecating set-output and save-state commands — The October 2022 announcement that moved step outputs from stdout commands to the file-based mechanism