GitHub Actions fromJSON matrix fails to expand
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.
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 ''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.
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 --versionCommon 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
- Add a step to the consumer job that echoes the raw output inside single quotes.
- Look at whether you got an empty string, a pair of brackets, or something with newlines in it.
- Only then decide whether you are fixing the producer or guarding the consumer.
- 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.
- 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.
test:
needs: gen
if: needs.gen.outputs.list != '[]' && needs.gen.outputs.list != ''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.
- id: set
run: |
echo 'list=[{"name":"api","node":"22"},{"name":"web","node":"20"}]' >> "$GITHUB_OUTPUT"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.
// 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.
// 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 |
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.
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.
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.
Frequently asked questions
Why does my dynamic matrix job fail instead of being skipped?
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?
Can a matrix dimension be a JSON object rather than an array?
Why does the error name the consumer job and not the producer?
Related guides
References
- actions/runner: WorkflowTemplateConverter.cs, the matrix vector and include checks
- quarkusio/quarkus#46708: Error parsing fromJson on an empty matrix input
- aws/amazon-cloudwatch-agent#2165: guarding fifteen jobs against an empty matrix
- GitHub Actions: workflow syntax, jobs.<job_id>.strategy.matrix
- DevilPepper/dockerfiles#32: an empty expansion failing the build rather than skipping it
- GitHub Actions documentation