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(versionabove). Without it, there’s no handle to read from. - You append
name=valueto$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”:
- The producing step has an
idand writes to$GITHUB_OUTPUT. - The producing job declares an
outputs:map that references that step output. - The consuming job declares
needs: <producing-job>— this both creates the dependency and makesneeds.<job>.outputsavailable.
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_ENVis 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:
| Need | Use | How it travels |
|---|---|---|
| A value in later steps of the same job | $GITHUB_ENV | Environment 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 jobs | Job outputs: + needs | needs.<job>.outputs.<name> (≤1 MB/job) |
| Files or large/binary data between jobs | Artifacts | upload-artifact → download-artifact |
| Data reused across separate runs | Cache | actions/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.