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 JSON

Step 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_OUTPUT file as name=value lines. Visible only to later steps in the same job via steps.<step-id>.outputs.<name>. The step must have an id for 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 their needs: array, via needs.<job-id>.outputs.<name>. A step output is not automatically a job output — you have to re-export it.
  • Environment file — written to $GITHUB_ENV as NAME=value lines. 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 via process.env rather 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 test

The 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
    JSON

The 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=value lines (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_OUTPUT by a step with an id. Readable by later steps in the same job via steps.<id>.outputs.<name>. Always a string until parsed with fromJSON().
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 their needs: list. Readable as needs.<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.matrix whose values come from fromJSON() 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_OUTPUT and $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