self-host →

Passing data between steps and jobs in GitHub Actions

Learn the modern way to pass data between steps and jobs in GitHub Actions using step outputs, job outputs, $GITHUB_ENV, and artifacts — with copy-pasteable examples.

Most non-trivial workflows need to move a value from one place to another: a version number computed in one step, a build matrix generated by an early job, a compiled binary handed off to a deploy job. GitHub Actions gives you a few distinct mechanisms for this, and picking the right one matters. In this article you’ll learn the modern, supported ways to pass data — between steps in the same job, between separate jobs, and across entire runs — along with when to reach for each.

A quick note before we start: if you’ve seen older tutorials using ::set-output:: or ::save-state::, those workflow commands were deprecated and removed. Everything below uses the current environment-file approach ($GITHUB_OUTPUT, $GITHUB_ENV), which is what you should be writing today.

Step outputs: passing data between steps

The smallest unit of data passing is a step output. A step writes a name=value pair to the special file referenced by $GITHUB_OUTPUT, and later steps read it back through the steps context — as long as the producing step has an id.

name: Step outputs
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Compute a version
id: version
run: echo "value=1.4.2" >> "$GITHUB_OUTPUT"
- name: Use the version
run: echo "Building version ${{ steps.version.outputs.value }}"

The key pieces:

  • The producing step must declare an id (version above). Without it, there’s no handle to read from.
  • You append name=value to $GITHUB_OUTPUT — always quote the variable so a path with spaces won’t break.
  • Downstream steps read it with ${{ steps.<id>.outputs.<name> }}.

This replaces the old echo "::set-output name=value::1.4.2" command, which no longer works.

Multiline and structured values

A single name=value line can’t hold a value that contains newlines — think a JSON document, a changelog, or multi-line command output. For those, GitHub Actions supports a heredoc-style delimiter syntax: write name<<DELIMITER, then the value, then the delimiter again on its own line.

- name: Emit JSON output
id: meta
run: |
{
echo "json<<EOF"
cat data.json
echo "EOF"
} >> "$GITHUB_OUTPUT"

Here the whole block is grouped with { ... } >> "$GITHUB_OUTPUT" so every echo and the cat get appended to the file together. Pick a delimiter (EOF is conventional) that’s guaranteed not to appear inside the value — if you’re not sure, use a random string. You can then consume it like any other output, and parse it with fromJson if it’s JSON:

- name: Read a field from the JSON
run: echo "${{ fromJson(steps.meta.outputs.json).name }}"

Job outputs: passing data between jobs

Step outputs live and die within a single job. To hand a value to a different job, you promote a step output to a job output using the job’s outputs: map, and the downstream job reads it through the needs context.

There are three requirements, and missing any one of them is the usual reason this “doesn’t work”:

  1. The producing step has an id and writes to $GITHUB_OUTPUT.
  2. The producing job declares an outputs: map that references that step output.
  3. The consuming job declares needs: <producing-job> — this both creates the dependency and makes needs.<job>.outputs available.

Here’s a complete two-job example:

name: Job outputs
on: push
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.value }}
steps:
- name: Compute a version
id: version
run: echo "value=1.4.2" >> "$GITHUB_OUTPUT"
deploy:
runs-on: ubuntu-latest
needs: prepare
steps:
- name: Deploy that version
run: echo "Deploying ${{ needs.prepare.outputs.version }}"

The deploy job won’t start until prepare succeeds, and it reads the value with ${{ needs.prepare.outputs.version }}. A common real-world use of this pattern is generating a dynamic matrix in one job and consuming it in another — covered in the matrix strategy article.

RunsOn tip: Because job outputs flow through needs, your jobs serialize around those dependencies. When the downstream jobs do fan out, RunsOn lets them run with no concurrency limit on cheaper, faster runners in your own AWS account — so a wide deploy or test stage isn’t bottlenecked by GitHub’s hosted concurrency caps.

Job outputs have a size limit

Job outputs are meant for small string data — version numbers, flags, JSON blobs, image tags. They are not a file-transfer channel. GitHub caps outputs at 1 MB per job and 50 MB total per workflow run (measured on UTF-16 encoding). If you try to pass a compiled artifact or a large dataset this way, you’ll hit those limits. For anything file-shaped or large, use artifacts instead (next section).

$GITHUB_ENV: environment variables for later steps in the same job

$GITHUB_OUTPUT is for values you address by name. $GITHUB_ENV is for values you want available as environment variables in every subsequent step of the same job. This is convenient when several later steps all need the same value and you’d rather not repeat a steps.<id>.outputs expression everywhere.

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Set an env var
run: echo "BUILD_ID=$(date +%s)" >> "$GITHUB_ENV"
- name: Use it
run: echo "Build is $BUILD_ID"

Two important caveats:

  • The variable is not available in the step that sets it — only in subsequent steps. Within the same step, just use a normal shell variable.
  • $GITHUB_ENV is job-local. It does not cross into other jobs. If a downstream job needs the value, use a job output as shown above.

Multiline values work here with the same heredoc delimiter syntax as outputs:

- name: Set a multiline env var
run: |
{
echo "NOTES<<EOF"
cat release-notes.md
echo "EOF"
} >> "$GITHUB_ENV"

For a deeper look at the different kinds of environment variables and how they’re scoped, see the dedicated environment variables article.

Masking sensitive values in logs

If a value you compute is sensitive — a generated token, a short-lived credential — register it with the add-mask workflow command so GitHub replaces it with *** anywhere it appears in the logs:

- name: Generate and mask a token
run: |
TOKEN="$(./generate-token.sh)"
echo "::add-mask::$TOKEN"
echo "TOKEN=$TOKEN" >> "$GITHUB_ENV"

Mask the value before you write it anywhere it might be printed. Note that secrets you reference from secrets.* are masked automatically — add-mask is for values you derive at runtime.

Artifacts: passing files and large data between jobs

When the thing you need to pass is a file — a compiled binary, a coverage report, a tarball, a built site — job outputs are the wrong tool. Upload it as an artifact in one job and download it in another. Artifacts handle binary data and have far larger limits than job outputs.

name: Build and deploy
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build
run: |
mkdir -p dist
npm ci && npm run build
- name: Upload build output
uses: actions/upload-artifact@v7
with:
name: dist
path: dist/
deploy:
runs-on: ubuntu-latest
needs: build
steps:
- name: Download build output
uses: actions/download-artifact@v8
with:
name: dist
path: dist/
- name: Deploy
run: ./deploy.sh dist/

Artifacts persist for the lifetime of the run (and a retention window after), so they’re also how you make build outputs downloadable from the run’s summary page.

Choosing the right tool

These four mechanisms overlap a little, but each has a clear sweet spot:

NeedUseHow it travels
A value in later steps of the same job$GITHUB_ENVEnvironment variable, subsequent steps only
A named value in later steps of the same job$GITHUB_OUTPUT (step output)steps.<id>.outputs.<name>
Small string data between jobsJob outputs: + needsneeds.<job>.outputs.<name> (≤1 MB/job)
Files or large/binary data between jobsArtifactsupload-artifactdownload-artifact
Data reused across separate runsCacheactions/cache, keyed restore

A rough decision flow: within a job, reach for $GITHUB_ENV or step outputs. Between jobs, use job outputs for a small string and artifacts for anything file-shaped or over the size limit. And if you want the same data to survive from one workflow run to the next — dependencies, build caches — that’s caching, covered in the caching article.

Conclusion

Passing data in GitHub Actions comes down to matching the mechanism to the distance and shape of the data: $GITHUB_ENV and step outputs stay inside a job, job outputs carry small strings across the needs graph, artifacts move files between jobs, and caching carries data across runs. Stick to the environment-file syntax (>> "$GITHUB_OUTPUT" and >> "$GITHUB_ENV"), use the heredoc delimiter trick for multiline values, mask anything sensitive, and you’ll have clean, predictable data flow through even complex pipelines.