Skip to content

Fix: GitHub Actions Matrix Strategy Not Working — Jobs Not Running or Failing

FixDevs · (Updated: )

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 expand

Or 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 expanded

Or all matrix jobs fail when one fails, even with fail-fast: false:

strategy:
  fail-fast: false   # Set, but jobs still cancel when one fails

Or 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 only

Why 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 $VARIABLE shell syntax (which only works for env: variables).
  • fail-fast: true is the default — all in-progress and queued matrix jobs are cancelled when any job in the matrix fails. Set fail-fast: false explicitly to let all jobs complete.
  • exclude must match exactly — if an exclude entry 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 test

Allow 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 set

Fix 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: 18

Result:

  • 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 windows

Fix 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 job

Proper 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 once

Job-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 mistakes

Check 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 valuesmatrix: { 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 valuestrue 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles