GitHub Actions needs outputs empty between jobs
GitHub Actions needs outputs empty is what you get when a job reads a value the producing job never published. Step outputs are local to the job that set them, so crossing a job boundary takes an explicit map on the producer and a dependency on the consumer, and missing either one gives you an empty string rather than an error.

What this error means
Nothing is red. The producing job is green, the step that set the output is green, and the consuming job runs to completion with a blank where a value should have been. Downstream that shows up as a docker tag of myapp:, a deploy to an environment named nothing, or a conditional that never fires. There is no annotation, because there is nothing GitHub considers wrong: the contexts reference says that "If you attempt to dereference a nonexistent property, it will evaluate to an empty string", so an output that was never published and one whose name you typed wrong look alike.
{
"build": {
"result": "success",
"outputs": {
"build_id": "123456"
}
},
"deploy": {
"result": "failure",
"outputs": {}
}
}A minimal workflow that produces it
This file is written for this page and has never been run. The producing step writes its output correctly and the consuming job declares its dependency correctly. What is missing is the piece that is not automatic: the producing job never says which of its step outputs it publishes, so there is nothing to read and no complaint from anybody.
name: release
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- id: version
run: echo "ver=1.4.0" >> "$GITHUB_OUTPUT"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "deploying ${{ needs.build.outputs.ver }}"Common causes
The producing job never mapped the step output
The ordinary case, and the one the corrected file above addresses. The step wrote its value and later steps in the same job can read it, but the job published nothing, so the map the consumer reads is empty. Everything is green because nothing failed.
The consumer does not depend on the producer
The needs context holds direct dependencies only. A job that reads needs.build.outputs.ver while naming only test in its own needs gets an empty string, because build is not in the context at all. Adding the name also adds the wait, which is usually what you wanted.
The value was masked
Anything registered as a secret or masked with a workflow command is stripped from job outputs, and the producer logs a skip warning rather than failing. This is the cause that survives every round of checking the names, because the map is right and the value is not allowed through.
The producer did not succeed
A skipped job publishes nothing, so its outputs map is empty. A failed job is different: outputs are evaluated at the end of the job, so it still publishes whatever its finished steps set, and only values from steps that never ran are missing. The result field tells you which case you are in.
How to fix it
Print the context before you change anything
- Add one step to the consuming job that dumps the
needscontext as JSON. - If the job you expected is absent, the dependency is missing; if it is there with an empty outputs map, the producer published nothing.
- If it is populated, compare the output name character by character against the expression.
- run: echo "$NEEDS"
env:
NEEDS: ${{ toJSON(needs) }}Map every value the next job needs
Put the map on the job, not on the step, and give every entry the same name on both sides. A job that publishes several values costs nothing extra, and the map doubles as the documentation of what this job is for.
build:
runs-on: ubuntu-latest
outputs:
ver: ${{ steps.version.outputs.ver }}
sha: ${{ steps.version.outputs.sha }}Pass identifiers, not payloads
Job outputs are for small values: a version, a tag, a SHA, a matrix definition. Anything that might grow belongs in an artifact, with the artifact name passed as the output. That keeps you under both ceilings and survives the day somebody adds a field to the JSON.
Stop routing masked values through outputs
If the value is a secret, the consumer should read the secret rather than receive it. If it is not a secret but shares a string with one, a registry hostname inside a masked credential for example, rebuild it in the consumer from parts, because the mask applies to that string wherever it appears.
A step output does not leave its job on its own
The workflow syntax reference states the mechanism plainly: "You can use jobs.<job_id>.outputs to create a map of outputs for a job. Job outputs are available to all downstream jobs that depend on this job." The map is the publication step, and without it the step output exists only inside the job that produced it.
The receiving half has its own rule. The needs context "contains outputs from all jobs that are defined as a direct dependency of the current job. Note that this doesn't include implicitly dependent jobs (for example, dependent jobs of a dependent job)." Direct is the operative word.
One more sentence turns both mistakes into silence: "If you attempt to dereference a nonexistent property, it will evaluate to an empty string."
The corrected file, with the map added
Two lines fix the workflow above, and they go on the producer rather than the consumer. The map names the output the job publishes and points it at the step that set it, by that step's id. Read the two samples as a pair: the first prints "deploying" and then nothing, the second prints the version.
The step output is ver and the job output is also ver here, but they are independent and the map is the only thing connecting them. Keeping them identical is worth doing, because the commonest variant of this failure is a producer that publishes version and a consumer that reads ver.
jobs:
build:
runs-on: ubuntu-latest
outputs:
ver: ${{ steps.version.outputs.ver }}
steps:
- id: version
run: echo "ver=1.4.0" >> "$GITHUB_OUTPUT"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "deploying ${{ needs.build.outputs.ver }}"Three other ways the value arrives empty
When the map is already there and the value is still blank, three documented behaviors account for almost all of what is left.
The first is masking: "If an output is skipped because it may contain a secret, you will see the following warning message: \"Skip output {output.Key} since it may contain secret.\"" That catches more than secrets, because anything masked in the log is masked everywhere. actions/runner#2316 is the canonical report.
The second is size: "Outputs can be a maximum of 1 MB per job. The total of all outputs in a workflow run can be a maximum of 50 MB." A build log or a JSON inventory crosses that sooner than people expect.
The third is a matrix, where every leg merges into one map: "Actions does not guarantee the order that matrix jobs will run in. Ensure that the output name is unique, otherwise the last matrix job that runs will override the output value."
| Where the value is | Who can read it | What a miss looks like |
|---|---|---|
$GITHUB_OUTPUT | later steps in the same job | empty string |
steps.<id>.outputs.<name> | the same job only | empty string |
jobs.<id>.outputs.<name> | jobs that name it in needs | empty string |
| masked value | nobody downstream | a skip warning in the producer |
| over 1 MB | nobody downstream | the output is rejected |
Why there is no recorded run on this page
There is no error to reproduce. The value is absent, the expression evaluates to an empty string, and both jobs report success, so a reproduction would be a green run with a blank in it. Nothing is transient and nothing is repairable by a runner, because the workflow is doing what it says. The payload at the top is the example the contexts reference publishes, quoted to show an empty outputs map beside a populated one.
How to prevent it
- Treat
jobs.<id>.outputsas part of the job signature and write it at the same time as the step that fills it. - Keep the step output name, the job output name and the expression identical in all three places.
- Name every job whose output you read in your own
needs, because the context is direct dependencies only. - Print
toJSON(needs)once while wiring a new dependency up, and delete the step when it is working.
Frequently asked questions
Why does a job output not reach the next job?
jobs.<job_id>.outputs map on the producer. Without it the expression dereferences a property that does not exist, and the contexts reference says that "will evaluate to an empty string" rather than raising an error.Do job outputs work with a matrix?
Why does a job output come through empty when it is a secret?
{output.Key} since it may contain secret." in the producing job. Masking is by string, so an unrelated value containing a masked substring is dropped too. actions/runner#2316 is that report.Can a job read the outputs of a job two levels up?
needs context "doesn't include implicitly dependent jobs (for example, dependent jobs of a dependent job)", so a job that wants a value from the top of a chain has to list that job in its own needs too. The alternative is to forward it one level at a time.