Fix: GitHub Actions Matrix Strategy Not Working — Jobs Not Running or Failing
Part of: Docker, DevOps & Infrastructure
Quick Answer
How to fix GitHub Actions matrix strategy issues — matrix expansion, include/exclude patterns, failing fast, matrix variable access, and dependent jobs with matrix outputs.
The Problem
A GitHub Actions matrix strategy doesn’t generate the expected jobs:
strategy:
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
# Expected: 6 jobs (3 x 2)
# Actual: Only some jobs run or the matrix fails to expandOr a matrix variable is not accessible in a step:
matrix:
version: ['1.0', '2.0']
steps:
- run: echo "Version is ${{ matrix.version }}"
# Outputs: "Version is " — variable not expandedOr all matrix jobs fail when one fails, even with fail-fast: false:
strategy:
fail-fast: false # Set, but jobs still cancel when one failsOr an include to add a specific combination doesn’t work as expected:
matrix:
os: [ubuntu-latest, windows-latest]
include:
- os: macos-latest
node: 18 # Should add macOS with node 18 onlyWhy This Happens
GitHub Actions matrix strategy has several behaviors that aren’t obvious from the documentation alone.
The first surprise is YAML type coercion. A matrix value like 3.10 (intended as Python version) is parsed as the float 3.1 by the YAML parser, which silently drops the trailing zero. Your workflow says python: [3.9, 3.10, 3.11], but the matrix receives [3.9, 3.1, 3.11]. The setup-python action then fails because Python 3.1 doesn’t exist. This affects any version number that looks like a float: 3.10, 1.20, 2.0. The fix is to quote them as strings: ['3.9', '3.10', '3.11'].
The second common surprise is the include/exclude semantics. include does not replace matrix entries — it extends them. An include entry that matches existing key values adds new properties to that combination. An include entry with a key value that doesn’t exist in the base matrix creates an entirely new combination. This additive behavior confuses developers who expect include to work like a filter or override.
Other failure causes:
- Matrix variable access — use
${{ matrix.variable }}in YAML context, not$VARIABLEshell syntax (which only works forenv:variables). fail-fast: trueis the default — all in-progress and queued matrix jobs are cancelled when any job in the matrix fails. Setfail-fast: falseexplicitly to let all jobs complete.excludemust match exactly — if anexcludeentry doesn’t exactly match a generated combination’s values, it’s silently ignored.- Large matrix limits — free GitHub Actions accounts have concurrency limits. A 10x10 matrix (100 jobs) may queue most jobs.
- Dynamic matrix — generating a matrix from a previous job output requires specific JSON formatting.
Diagnostic Timeline
When matrix jobs don’t run as expected, there is a systematic way to isolate the issue.
Minute 0 — Check what actually ran. Open the workflow run in the GitHub UI. The left sidebar shows every job that was generated by the matrix. Count them. If you expected 6 (3 nodes x 2 OS) and see 5, one combination was excluded or the matrix values are wrong. If you see 0 jobs, the matrix itself failed to expand.
Minute 2 — Add a toJSON(matrix) debug step. This is the single most useful debugging technique for matrix issues. Add it to the workflow and re-run:
steps:
- name: Debug matrix
run: echo '${{ toJSON(matrix) }}'This prints the exact values the matrix generated for that job. Check for type mismatches (float instead of string), missing keys, or unexpected values.
Minute 5 — Check YAML type coercion. If a matrix value looks like a number, YAML parses it as one. 3.10 becomes 3.1. 1.0 stays 1. 22 stays 22 (integer, not string). Quote any value that must be a string:
# WRONG — 3.10 becomes float 3.1
python: [3.9, 3.10, 3.11]
# CORRECT — quoted strings preserve exact values
python: ['3.9', '3.10', '3.11']Minute 8 — Verify include/exclude syntax. An exclude entry must match every key in the combination exactly. If your matrix has os and node, the exclude must specify both. An include entry that defines a key not in the base matrix creates a new standalone combination — this is often unintentional.
Minute 10 — Check for fail-fast masking the real error. If fail-fast is true (the default) and one job fails, all other jobs are cancelled. The cancelled jobs show a grey “cancelled” badge, not a red failure. You might think the matrix didn’t expand correctly when in reality one job failed and cancelled the rest. Set fail-fast: false temporarily to see all results.
Minute 12 — Validate locally with actionlint. Run actionlint .github/workflows/test.yml. It catches matrix syntax errors, invalid expression contexts, and type issues that GitHub won’t surface until runtime.
Fix 1: Access Matrix Variables Correctly
Matrix variables use the ${{ matrix.* }} expression syntax everywhere in the job definition — steps, if conditions, job names, service definitions:
jobs:
test:
runs-on: ${{ matrix.os }} # matrix variable in runs-on
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]
include:
- os: ubuntu-latest
node: 20
experimental: true # Extra property for specific combination
name: Test on ${{ matrix.os }} — Node ${{ matrix.node }} # Job name
steps:
# CORRECT — use ${{ matrix.* }} in GitHub Actions expressions
- name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
# CORRECT — works in run: steps too
- run: echo "Testing on Node ${{ matrix.node }} / ${{ matrix.os }}"
# WRONG — $MATRIX_NODE is not set by default (it's not in env:)
- run: echo "Node is $MATRIX_NODE" # Outputs empty string
# If you need it as an env var, set it explicitly
- name: Set matrix vars as env
env:
NODE_VERSION: ${{ matrix.node }}
run: echo "Node is $NODE_VERSION" # Now works
# Conditional step using matrix value
- if: ${{ matrix.experimental == true }}
run: echo "This is an experimental build"Fix 2: Disable fail-fast to Run All Matrix Jobs
By default, GitHub cancels all remaining matrix jobs when one fails. Disable fail-fast to see results across all configurations:
jobs:
test:
strategy:
fail-fast: false # All jobs run even if one fails
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci && npm testAllow specific combinations to fail without failing the whole matrix:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]
include:
- os: windows-latest
node: 20
allow-failure: true # Custom property
steps:
- run: npm test
continue-on-error: ${{ matrix.allow-failure == true }}
# This step won't fail the job if allow-failure is setFix 3: Understand include and exclude Behavior
include and exclude have specific semantics that differ from most templating systems:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]
# include: Adds new combinations or extra properties to existing ones
include:
# Adds NEW combination (macos + node 18) — not in the base matrix
- os: macos-latest
node: 18
# Adds 'experimental: true' to the EXISTING ubuntu + node 20 combination
- os: ubuntu-latest
node: 20
experimental: true
# exclude: Removes specific combinations from the base matrix
exclude:
# Removes the windows + node 18 combination
- os: windows-latest
node: 18Result:
- ubuntu + 18
- ubuntu + 20 (with
experimental: true) - windows + 20 (node 18 excluded)
- macos + 18 (added by include)
# Common mistake — using include to REPLACE instead of ADD
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
# include with NO existing matrix keys creates an additional combination
include:
- os: macos-latest # macOS IS added as a new combination
# But it doesn't REPLACE ubuntu or windowsFix 4: Create Dynamic Matrices from Previous Jobs
A matrix can be generated dynamically from a previous step’s output — useful for generating test suites from file discovery:
jobs:
# Job 1: Generate the matrix
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- name: Generate matrix
id: set-matrix
run: |
# Generate a JSON array of values
VERSIONS=$(node -e "console.log(JSON.stringify(['18', '20', '22']))")
echo "matrix={\"node\":${VERSIONS}}" >> $GITHUB_OUTPUT
# Or from file discovery:
# TESTS=$(find tests -name "*.spec.ts" | jq -R -s -c 'split("\n")[:-1]')
# echo "matrix={\"test\":${TESTS}}" >> $GITHUB_OUTPUT
# Job 2: Use the dynamic matrix
test:
needs: setup
strategy:
matrix: ${{ fromJson(needs.setup.outputs.matrix) }}
runs-on: ubuntu-latest
steps:
- run: echo "Running on Node ${{ matrix.node }}"Generate matrix from a JSON file in the repo:
- name: Set matrix from file
id: set-matrix
run: echo "matrix=$(cat .github/test-matrix.json)" >> $GITHUB_OUTPUT// .github/test-matrix.json
{
"include": [
{ "os": "ubuntu-latest", "node": "18" },
{ "os": "windows-latest", "node": "20" },
{ "os": "macos-latest", "node": "22" }
]
}Fix 5: Use Matrix Outputs in Dependent Jobs
Getting results from matrix jobs into a subsequent job requires collecting all outputs:
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
outputs:
# Matrix jobs can set outputs — but only one value survives (last write wins)
artifact-path: ${{ steps.build.outputs.artifact-path }}
steps:
- id: build
run: |
ARTIFACT="build-${{ matrix.os }}-${{ github.sha }}.tar.gz"
echo "artifact-path=$ARTIFACT" >> $GITHUB_OUTPUT
deploy:
needs: build
runs-on: ubuntu-latest
steps:
# WARNING: Only the LAST matrix job's output is available here
- run: echo "Artifact: ${{ needs.build.outputs.artifact-path }}"
# This is a GitHub Actions limitation — all matrix jobs write to same output key
# WORKAROUND: Upload artifacts from each matrix job and download in deploy jobProper pattern — use artifacts for matrix job outputs:
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Build
run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: build-${{ matrix.os }} # Unique name per matrix job
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/
# Downloads all uploaded artifacts into artifacts/ directory
- name: List artifacts
run: ls -la artifacts/
# artifacts/build-ubuntu-latest/
# artifacts/build-windows-latest/Fix 6: Limit Matrix Concurrency
In large matrices or with limited runners, control concurrency to avoid overwhelming infrastructure:
jobs:
test:
strategy:
fail-fast: false
max-parallel: 3 # At most 3 jobs run simultaneously (from any matrix)
matrix:
node: [16, 18, 20, 22]
os: [ubuntu-latest, windows-latest, macos-latest]
# 12 total jobs, but max 3 run at onceJob-level concurrency group — cancel in-progress runs when new ones start:
jobs:
test:
concurrency:
group: test-${{ matrix.os }}-${{ matrix.node }}-${{ github.ref }}
cancel-in-progress: true # Cancel previous run for same combination
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]Fix 7: Debug Matrix Expansion
When you’re unsure what combinations the matrix generates, inspect the workflow before running it:
jobs:
debug-matrix:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]
include:
- os: macos-latest
node: 22
runs-on: ${{ matrix.os }}
steps:
- name: Print matrix context
run: |
echo "OS: ${{ matrix.os }}"
echo "Node: ${{ matrix.node }}"
echo "Full matrix: ${{ toJSON(matrix) }}"GitHub’s matrix visualization — the GitHub Actions UI shows all matrix job combinations in the workflow run view. Each combination appears as a separate job in the job list on the left side.
Validate YAML syntax locally:
# Install actionlint — GitHub Actions linter
brew install actionlint # macOS
# Or: go install github.com/rhysd/actionlint/cmd/actionlint@latest
# Validate the workflow file
actionlint .github/workflows/test.yml
# Catches matrix syntax errors, invalid contexts, and common mistakesCheck effective matrix with GitHub CLI:
# View recent workflow runs and their jobs
gh run list --workflow=test.yml
# View jobs in a specific run
gh run view <run-id> --json jobs --jq '.jobs[].name'Still Not Working?
Matrix with empty values — an empty array in a matrix dimension (node: []) creates zero combinations for that dimension, resulting in no jobs. Validate that your dynamic matrix generation produces non-empty arrays.
String vs number matrix values — matrix: { node: [18, 20] } creates integer values. Some actions expect strings. If you see type mismatch errors, quote the values: node: ['18', '20'].
Secrets not available in matrix jobs — secrets are available in all matrix jobs by default. If a secret appears empty, check it’s defined in the repository/environment settings and the job has access to the environment.
if condition on matrix job — to conditionally run matrix jobs, the if expression is evaluated per job:
jobs:
test:
if: ${{ github.event_name != 'pull_request' || matrix.os != 'windows-latest' }}
# Skip Windows tests on pull requests — run all on push
strategy:
matrix:
os: [ubuntu-latest, windows-latest]Dynamic matrix from fromJson returns no jobs — the JSON string must be valid and the output must be set before the dependent job reads it. A common mistake is setting the output inside a conditional block that doesn’t execute, leaving the output empty. Add a debug step that prints the raw output:
- run: echo "Raw matrix output: ${{ needs.setup.outputs.matrix }}"If that prints empty, the setup job didn’t set the output correctly.
Matrix with boolean values — true and false in YAML are booleans, not strings. If you need to compare them in if conditions, use == true not == 'true'. And in toJSON output, they appear as JSON true/false, not quoted strings.
Reusable workflow inputs and matrix — matrices defined in a reusable workflow (called with uses:) must be passed through with: inputs. You cannot pass a matrix directly to a reusable workflow’s strategy. Define the matrix inside the reusable workflow itself or pass the matrix JSON as a string input and decode it with fromJson.
For related GitHub Actions issues, see Fix: GitHub Actions Docker Build Push, Fix: GitHub Actions Permission Denied, Fix: GitHub Actions Cache Not Working, and Fix: GitHub Actions Secret Not Available.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: GitHub Actions Artifacts Not Working — Upload Fails, Download Empty, or Artifact Not Found
How to fix GitHub Actions artifact issues — upload-artifact path patterns, download-artifact across jobs, retention days, artifact name conflicts, and the v3 to v4 migration.
Fix: GitHub Actions Secret Not Available — Environment Variable Empty in Workflow
How to fix GitHub Actions secrets that appear empty or undefined in workflows — secret scope, fork PR restrictions, environment protection rules, secret names, and OIDC alternatives.
Fix: GitHub Actions Docker Build and Push Failing
How to fix GitHub Actions Docker build and push errors — registry authentication, image tagging, layer caching, multi-platform builds, and GHCR vs Docker Hub setup.
Fix: GitHub Actions Environment Variables Not Available Between Steps
How to fix GitHub Actions env vars and outputs not persisting between steps — GITHUB_ENV, GITHUB_OUTPUT, job outputs, and why echo >> $GITHUB_ENV is required.