# GitHub Actions step output empty: the missing or misspelled step id

> A GitHub Actions step output empty in an expression is a missing id, a misspelled one, a read that runs too early, or a value in another job.

Source: https://latchkey.dev/learn/github-actions/gha-steps-context-missing-step-id  
Updated: 2026-09-20

A GitHub Actions step output empty in a later step is a reference problem rather than an expression problem. The expression is valid, so nothing is rejected and nothing turns red; a property that is not there evaluates to an empty string, and every step downstream behaves as though the value was never set.

## What this error means

The workflow is valid, the run is green, and something downstream ran with a blank where a version, a tag or a path should have been. A condition gated on the value never fires, or fires every time, depending on which way round the comparison was written. A command receives an empty argument and either fails with a message about the argument or succeeds against the wrong target, which is the expensive version. Dumping the whole context is the usual next move and it produces the same puzzle in a smaller form: an empty object rather than a listing. Nothing in the log says why, because from the parser point of view nothing went wrong: it was asked for a property, the property was not there, and it returned what the documentation says it returns.

```Actions log, quoted from actions/toolkit#338 (timestamps removed)
##[group]Run echo "$STEPS_CONTEXT"
shell: /usr/bin/bash -e {0}
env:
  STEPS_CONTEXT: {}
##[endgroup]
```

## Common causes

### The producing step has no id

The common one, and the one that makes a context dump come back as an empty object. A step that writes to the output file without declaring an id has done the work and thrown away the label, so nothing can name it.

### The id or the output name does not match

A rename on one side, a hyphen against an underscore, or a difference in case. The lookup misses and returns an empty string, so the workflow keeps running with a blank. Nothing compares the two spellings for you, which is why this survives review more often than it should.

### The reference runs before the step it names

The context only holds steps that "have already run", so an expression in an earlier step, or in a job-level key evaluated at planning time, reads nothing. In our experience this arrives when steps are reordered and a condition that used to sit at the bottom ends up above the step that feeds it.

### The step is in another job

A second job has its own context and cannot reach the first one. The value has to be promoted to a job output and read through `needs`, and a job that is not named in `needs` cannot read it even then, because that context holds direct dependencies only.

## How to fix it

### Declare the id on the step that sets the output

1. Add `id:` to the producing step and use the same value in every reference.
2. Keep the output name identical to the key written into the output file, including case.
3. Put the reference after the producing step in the same job.

### Print the value before you branch on it

One echo step tells you whether the output exists, which no condition can. Leave it in while you are wiring up a new output and remove it once the workflow is stable, or keep it behind a debug input if the output feeds anything expensive.

```.github/workflows/release.yml (illustrative)
- run: echo "meta.tag is [${{ steps.meta.outputs.tag }}]"
```

### Promote it to a job output for anything cross-job

Declare `outputs` on the producing job, build each entry from the steps context, and read it from a job that names the producer in `needs`. This is also the only way to get a value into a job-level key such as `runs-on` or a job condition, neither of which can see steps. The producer below emits one JSON array, which the next fix expands into a matrix.

```.github/workflows/ci.yml (illustrative)
jobs:
  plan:
    runs-on: ubuntu-latest
    outputs:
      shards: ${{ steps.calc.outputs.shards }}
    steps:
      - id: calc
        run: echo 'shards=["1","2","3","4"]' >> "$GITHUB_OUTPUT"
```

### Cast it back when you need a type

Because the value is text by the time anything reads it, a numeric comparison or a matrix built from it needs `fromJSON`. That function returns a real JSON data type, which is what turns a quoted number into a number, and the array the job above emitted into a matrix the runner can expand.

```.github/workflows/ci.yml (illustrative)
test:
    needs: plan
    strategy:
      matrix:
        shard: ${{ fromJSON(needs.plan.outputs.shards) }}
    runs-on: ubuntu-latest
    steps:
      - run: ./test.sh --shard ${{ matrix.shard }}
```

## How to prevent it

- Add an `id` in the same commit that first writes an output, not when something downstream needs it.
- Keep output keys lowercase with hyphens, so a case difference cannot hide between two files.
- Use job outputs and `needs` for anything that crosses a job, and never expect the steps context to travel.
- Echo a new output once during wiring, because an empty string and a false comparison look identical.

## A minimal workflow that produces it

This file is written for this page and has never been run. The first step writes an output correctly, to the file the runner gives it for exactly this purpose. It just never declared an id, so there is no name under which anything can read it back, and the second step prints an empty line.

```.github/workflows/release.yml (illustrative)
name: release
on: [push]

jobs:
  tag:
    runs-on: ubuntu-latest
    steps:
      - run: echo "tag=v1.4.2" >> "$GITHUB_OUTPUT"
      - run: echo "releasing ${{ steps.meta.outputs.tag }}"
```

## Two conditions, and the docs state both

The contexts reference defines the membership rule in a single sentence: "The `steps` context contains information about the steps in the current job that have an `id` specified and have already run." Both halves are load-bearing. A step without an id is not in the context at all, and a step that has not run yet is not in it either, which is why a reference that points backwards works and one that points forwards does not.

The behavior when the lookup misses is documented in the same reference: "If you attempt to dereference a nonexistent property, it will evaluate to an empty string." That is the whole reason this is hard to spot. There is no error, no warning and no annotation, so the failure surfaces wherever the empty string first causes trouble, which is usually several steps later.

## Give the step an id and read it afterwards

The correction is two words long. Declare the id on the step that produces the value, use the same id in the expression, and keep the reference after the step in the file. Match the case of both the id and the output name, and keep the output name identical to the key you wrote into the output file.

While you are wiring it up, print the value once rather than branching on it. A comparison against an empty string is true or false for reasons that have nothing to do with the output, so a condition is the worst possible instrument for finding out whether an output exists.

```.github/workflows/release.yml, corrected (illustrative)
jobs:
  tag:
    runs-on: ubuntu-latest
    steps:
      - id: meta
        run: echo "tag=v1.4.2" >> "$GITHUB_OUTPUT"
      - run: echo "releasing ${{ steps.meta.outputs.tag }}"
```

## The context does not cross a job boundary

A second job cannot see the first job steps, whatever ids they declared, because the context belongs to the job that is running. The route between jobs is a job output: the producing job declares `outputs` built from its own steps context, the consuming job names it in `needs`, and reads `needs.<job_id>.outputs.<name>`.

One property of that route is worth knowing before you build logic on it. The expressions reference says a step output "evaluates as a string", so a value that was a number or a boolean in the step is text by the time anything reads it, in the same job or the next one. `fromJSON` is the documented way to get the type back.

```.github/workflows/release.yml (illustrative)
jobs:
  tag:
    runs-on: ubuntu-latest
    outputs:
      tag: ${{ steps.meta.outputs.tag }}
    steps:
      - id: meta
        run: echo "tag=v1.4.2" >> "$GITHUB_OUTPUT"

  publish:
    needs: tag
    runs-on: ubuntu-latest
    steps:
      - run: ./publish.sh "${{ needs.tag.outputs.tag }}"
```

## Why there is no recorded run on this page

There is no failure to record. The workflow is valid, the job is green, and the empty string is the documented result of the lookup, so a runner has nothing to detect, retry or repair here. The log block at the top is quoted from a public issue with the timestamps removed, and the workflows above are illustrative rather than reproduced.

> A job-level key cannot read this context at all, which produces a different message: [GitHub Actions unrecognized named-value](/learn/github-actions/gha-bad-expression-context-access).

## FAQ

### Why is steps.<id>.outputs empty in GitHub Actions?

Because the lookup missed and the documentation says a nonexistent property "will evaluate to an empty string". The two usual reasons are that the producing step never declared an id, so it is not in the context at all, or that the id and the reference are spelled differently. Neither produces an error, which is why the blank shows up later.

### Why is toJson(steps) empty?

The context holds only steps "that have an `id` specified and have already run", so a job whose steps declare no ids has nothing to dump. A blank dump is not proof of a missing id, though: actions/toolkit#338 reports `STEPS_CONTEXT: {}` from a workflow that did put ids on two steps. Echo the one output you care about instead of the whole context.

### How do I pass a value from one job to another?

Declare it under the producing job `outputs`, built from that job steps context, then name the producer in `needs` and read `needs.<job_id>.outputs.<name>`. The steps context never crosses a job boundary, and the needs context holds direct dependencies only, so a job two levels down has to name the producer itself.

### Does a step output keep its type?

No. The expressions reference states that a step output "evaluates as a string", so a number or a boolean written by a step is text everywhere it is read. Use `fromJSON` when you need a number for a comparison, a boolean for a typed input, or an array for a matrix.

## References

- [GitHub Actions: contexts reference, steps context](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#steps-context)
- [GitHub Actions: workflow syntax, jobs.<job_id>.steps[*].id](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsid)
- [actions/toolkit#338: toJson(steps) always empty](https://github.com/actions/toolkit/issues/338)
- [GitHub Actions: expressions, fromJSON](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#fromjson)

---

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
