# GitHub Actions fromJSON matrix fails to expand

> A GitHub Actions fromJSON matrix fails when the upstream output is empty or is not an array. Here are the four messages and which branch emits each.

Source: https://latchkey.dev/learn/github-actions/gha-fromjson-matrix-not-array  
Updated: 2026-09-20

A GitHub Actions fromJSON matrix that will not expand fails in the evaluator, not in the parser, and the message names the job rather than the expression. There are four distinct failures behind it and they are worth telling apart, because an empty list and a list of the wrong shape need opposite fixes.

## What this error means

The producer job is green. The consumer job never starts, and the run is marked failed rather than skipped, so a required check stays red and nothing downstream runs. The annotation opens with the name of the consumer job, which is misleading the first time you read it: the job did not fail, it was never built. Underneath that prefix sit one or more messages from the template evaluator, joined by commas with no space, each carrying its own file, line and column. The line and column point at the expression that produced the value, which is where the fix goes. Because the producer succeeded, nothing in its log says anything is wrong; the evidence is one step output that was empty, or shaped differently from what a matrix dimension accepts.

```Actions annotation, quoted from quarkusio/quarkus#46708
Error when evaluating 'strategy' for job 'native-tests'. .github/workflows/ci-actions-incremental.yml (Line: 1242, Col: 15): Error parsing fromJson,.github/workflows/ci-actions-incremental.yml (Line: 1242, Col: 15): Error reading JToken from JsonReader. Path '', line 0, position 0.,.github/workflows/ci-actions-incremental.yml (Line: 1242, Col: 15): Unexpected value ''
```

## Common causes

### The producer step never wrote the output

The most common one by a distance, and the hardest to see, because the step exits zero either way. A conditional that did not fire, a `GITHUB_OUTPUT` write inside an `if` block, or a step id that does not match the one the job outputs map reads: all three leave the consumer with an empty string rather than with JSON.

### The JSON is valid but is not a list

A matrix dimension has to be a sequence. A script that emits an object keyed by name, or a single item without brackets, produces JSON that parses cleanly and then fails the type assertion. The message names the token type it received, which tells you what your script actually emitted.

### The list is genuinely empty

A filter matched nothing. This is the legitimate case, and it still fails the run rather than skipping it, so it needs a guard rather than a fix. In aws/amazon-cloudwatch-agent#2165 fifteen jobs shared this shape and each one needed the same one-line condition.

### The JSON was mangled on the way out

Multi-line JSON written to `GITHUB_OUTPUT` without the heredoc delimiter format, or a value passed through `toJSON` and then quoted again, arrive as something the function cannot read. Keeping the emitted JSON on one line is what makes this class of failure go away rather than move around.

## How to fix it

### Print the output before you consume it

1. Add a step to the consumer job that echoes the raw output inside single quotes.
2. Look at whether you got an empty string, a pair of brackets, or something with newlines in it.
3. Only then decide whether you are fixing the producer or guarding the consumer.

```.github/workflows/ci.yml (illustrative)
- name: Show what the producer emitted
        run: echo 'raw=${{ needs.gen.outputs.list }}'
```

### Emit a compact array on one line, unconditionally

Write the output on every path through the step, including the path where the list is empty. An empty array is a value the consumer can test; an unwritten output is not. Compacting with a JSON tool keeps the newlines out without you having to think about quoting.

```.github/workflows/ci.yml (illustrative)
- id: set
        run: |
          list=$(ls -d packages/*/ 2>/dev/null | jq -R -s -c 'split("\n")[:-1]')
          echo "list=${list:-[]}" >> "$GITHUB_OUTPUT"
```

### Guard the consumer against the empty case

Put the condition on the job, compare against the literal text, and let the job skip. Do this even after you fix the producer, because the empty case is usually legitimate and will come back the first time somebody adds a filter.

```.github/workflows/ci.yml (illustrative)
test:
    needs: gen
    if: needs.gen.outputs.list != '[]' && needs.gen.outputs.list != ''
```

> A skipped job takes everything that needs it with it. That cascade has its own page: [GitHub Actions job has been skipped](/learn/github-actions/github-actions-job-skipped-needs-result).

### Match the shape to the dimension

A dimension takes a list of scalars or a list of objects, and each entry becomes one job. If your script produces a map from names to settings, convert it to a list of objects before it leaves the producer, rather than trying to index into it from the matrix.

```.github/workflows/ci.yml (illustrative)
- id: set
        run: |
          echo 'list=[{"name":"api","node":"22"},{"name":"web","node":"20"}]' >> "$GITHUB_OUTPUT"
```

## How to prevent it

- Write the output on every code path, with an empty array as the fallback.
- Keep emitted JSON on one line, compacted by a tool rather than by hand.
- Guard every consumer of a dynamic matrix against the empty-list case.
- Echo the raw output once while wiring a new dynamic matrix up, then delete the step.

## A minimal workflow that produces it

This pair of jobs is written for this page and has never been run. The producer sets an output only on one branch of a condition, which is the usual way a dynamic matrix ends up empty without anybody noticing: the step succeeds, the output is never written, and the consumer receives an empty string.

What the consumer then does with that empty string depends on where the expression sits. Feeding it to `fromJSON` fails inside the function. Feeding the raw string to a dimension fails at the type check. Feeding a valid but empty array fails at the vector check. Three different messages, one root cause.

```.github/workflows/ci.yml (illustrative)
jobs:
  gen:
    runs-on: ubuntu-latest
    outputs:
      list: ${{ steps.set.outputs.list }}
    steps:
      - id: set
        run: |
          if [ -d packages ]; then
            echo 'list=["18","20","22"]' >> "$GITHUB_OUTPUT"
          fi

  test:
    needs: gen
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: ${{ fromJSON(needs.gen.outputs.list) }}
    steps:
      - run: node --version
```

## Where each message comes from

The prefix is one line of `WorkflowTemplateEvaluator.cs` in actions/runner. It is built before the evaluation runs and attached to whatever errors come out of it, which is why the job name appears in front of a problem that belongs to an expression.

The errors behind the prefix are joined by `TemplateValidationErrors.Check`, which puts the trimmed prefix, a single space, and then every message joined by a comma with nothing after it. That is the run-together punctuation in the annotation above, and it is worth knowing so you can split the line back into the separate findings it contains.

```actions/runner, src/Sdk/WorkflowParser
// WorkflowTemplateEvaluator.cs
var errorPrefix = $"Error when evaluating '{WorkflowTemplateConstants.Strategy}' for job '{jobId}'.";

// TemplateValidationErrors.cs
var message = $"{prefix.Trim()} {String.Join(",", m_errors.Select(e => e.Message))}";
```

## Four shapes, four messages

The table is the whole diagnosis. Work out which row you are on from the message, and the fix follows from the row rather than from guesswork about the JSON.

The first row is the quoted annotation above. `fromJSON` on an empty string throws inside Newtonsoft, the runner rewraps it, and the scalar that was left behind is then reported as an unexpected value, so a single empty output produces three messages at once.

The second row is a type check, not a JSON error: the JSON parsed, and what came out is not a sequence. A matrix dimension is read with an assertion that demands one, and the assertion names the token type it got.

```actions/runner, the four messages a dynamic matrix can produce
// FromJson.cs, when the string handed to the function is not JSON at all,
// with the Newtonsoft reader message behind it
Error parsing fromJson
Error reading JToken from JsonReader. Path '', line 0, position 0.

// TemplateTokenExtensions.cs, with the object description filled in
Unexpected type '<token type>' encountered while reading 'matrix vector value'. The type 'SequenceToken' was expected.

// WorkflowTemplateConverter.cs, the two matrix checks
Matrix vector '<name>' does not contain any values
Matrix must define at least one vector
```

| What the output holds | Which message you get |
| --- | --- |
| Empty string, output never written | The fromJson pair, then an unexpected empty value |
| Valid JSON that is not a list | The unexpected-type message for a matrix vector value |
| A valid but empty list | The named vector contains no values |
| No dimensions and no include entries | The matrix defines no vector at all |

> The messages above are the emitted forms; the two angle-bracket placeholders are the only parts filled in at run time. Row one of the table is the annotation quoted at the top of this page.

## An empty list is a failure, not a skip

This is the behavior most teams end up designing around. A matrix that expands to nothing does not produce zero jobs quietly; it fails, and the failure propagates into whatever aggregates the run. In DevilPepper/dockerfiles#32 the reporter hit it on a push that changed no files in the directory the matrix was built from, and the whole build went red for having nothing to do.

The remedy is a guard on the consumer, not a change to the producer. Compare the raw output against the two-character string that an empty array serializes to, before `fromJSON` ever sees it. A job that skips for a false condition is reported as skipped rather than failed, which is what you wanted in the first place.

```.github/workflows/ci.yml, guarded (illustrative)
test:
    needs: gen
    if: needs.gen.outputs.list != '[]' && needs.gen.outputs.list != ''
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: ${{ fromJSON(needs.gen.outputs.list) }}
```

## Why there is no recorded run on this page

A matrix is expanded before the consumer job exists, at the point where the workflow file is read. There is no runner involved and no step to retry, so this library has no log of its own to show you and no repair to offer. Both annotations on this page come from public repositories and are attributed where they appear.

## FAQ

### Why does my dynamic matrix job fail instead of being skipped?

Because a matrix with nothing in it is an error rather than an empty set. The evaluator reports `Matrix must define at least one vector` when there are no dimensions and no include entries, and the job is never created. Skipping is something you have to ask for, with a condition on the job that tests the upstream output before `fromJSON` is applied to it.

### What does Error parsing fromJson mean in a matrix?

That the string handed to the function was not JSON at all, which in practice means it was empty. The runner catches the reader exception and rethrows it under that wording, so you get the wrapper, the underlying reader message with a position of zero, and a complaint about the empty scalar that was left behind, all on one line.

### Can a matrix dimension be a JSON object rather than an array?

No. Each dimension is read with an assertion that demands a sequence, and an object fails it with a message naming the type that arrived. A list of objects is fine and is the usual way to carry several settings per job; a single object is not.

### Why does the error name the consumer job and not the producer?

The prefix is built from the job whose strategy is being evaluated, so it always names the consumer. The file, line and column inside the message point at the expression, which is also on the consumer. The producer is only implicated by what it failed to write, and its own log will be green.

## References

- [actions/runner: WorkflowTemplateConverter.cs, the matrix vector and include checks](https://github.com/actions/runner/blob/main/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs)
- [quarkusio/quarkus#46708: Error parsing fromJson on an empty matrix input](https://github.com/quarkusio/quarkus/issues/46708)
- [aws/amazon-cloudwatch-agent#2165: guarding fifteen jobs against an empty matrix](https://github.com/aws/amazon-cloudwatch-agent/issues/2165)
- [GitHub Actions: workflow syntax, jobs.<job_id>.strategy.matrix](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstrategymatrix)
- [DevilPepper/dockerfiles#32: an empty expansion failing the build rather than skipping it](https://github.com/DevilPepper/dockerfiles/issues/32)

---

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
