# GitHub Actions needs outputs empty between jobs

> GitHub Actions needs outputs empty is almost always an unmapped step output. Find the four places a value can vanish, then fix the right one.

Source: https://latchkey.dev/learn/github-actions/gha-needs-context-output-undefined  
Updated: 2026-09-20

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.

```GitHub Actions contexts reference, example contents of the needs context
{
  "build": {
    "result": "success",
    "outputs": {
      "build_id": "123456"
    }
  },
  "deploy": {
    "result": "failure",
    "outputs": {}
  }
}
```

## 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

1. Add one step to the consuming job that dumps the `needs` context as JSON.
2. If the job you expected is absent, the dependency is missing; if it is there with an empty outputs map, the producer published nothing.
3. If it is populated, compare the output name character by character against the expression.

```.github/workflows/release.yml (illustrative)
- 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.

```.github/workflows/release.yml (illustrative)
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.

## How to prevent it

- Treat `jobs.<id>.outputs` as 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.

## 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.

```.github/workflows/release.yml (illustrative)
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 }}"
```

## 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`.

```.github/workflows/release.yml, corrected (illustrative)
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 |

> If the step output is already empty inside the producing job, the boundary is not the problem: [GitHub Actions step output empty](/learn/github-actions/gha-steps-context-missing-step-id) covers that half.

## 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.

## FAQ

### Why does a job output not reach the next job?

Because the producing job never published the value. Step outputs stay inside their job, and crossing the boundary requires a `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?

Yes, and every leg merges into one map, which is the trap. The docs warn directly: "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." Build the name from the matrix value.

### Why does a job output come through empty when it is a secret?

GitHub drops outputs it believes carry a secret and logs "Skip output `{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?

Not without naming it. The `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.

## References

- [GitHub Actions: workflow syntax, jobs.<job_id>.outputs](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idoutputs)
- [GitHub Actions: contexts reference, needs context](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#needs-context)
- [actions/runner#2316: a masked value cannot be referenced in another job](https://github.com/actions/runner/issues/2316)
- [GitHub Actions: workflow commands, masking and passing a secret between jobs](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#example-masking-and-passing-a-secret-between-jobs-or-workflows)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
