GitHub Actions Matrix Builds

By Vishvesh Patel · DevOps EngineerUpdated September 28, 2026

Run one job across many versions and platforms. Matrix syntax, include and exclude, fail-fast, max-parallel, and how to stop a matrix from multiplying out of control.

A matrix runs the same job many times with different inputs. It is how you test across language versions and operating systems without writing the job out repeatedly, and its main hazard is that the number of runs multiplies faster than people expect.

Verified against GitHub Actions as of September 2026.

The Basic Form

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

That is 3 operating systems times 3 Node versions, so 9 jobs. Each consumes minutes, and on a private repository the Windows jobs bill at twice the Linux rate while macOS bills at ten times. This matrix costs roughly 3 + 6 + 30, or 39 Linux-equivalent minutes per run, for what reads as a three-line configuration.

Note that runs-on reads from the matrix. Any key in strategy.matrix becomes available as matrix.<key> throughout the job, including in runs-on, if conditions and step inputs.

Trimming the Combinations

The full cross product is rarely what you want. exclude removes specific combinations:

    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [18, 20, 22]
        exclude:
          # Only test the oldest Node on Linux.
          - os: windows-latest
            node: 18
          - os: macos-latest
            node: 18

include adds a combination, or adds extra variables to an existing one:

    strategy:
      matrix:
        node: [20, 22]
        include:
          - node: 22
            experimental: true
          - node: 23
            experimental: true

The two behaviours of include are worth separating, because they surprise people. An include entry whose keys all match an existing combination adds variables to it: the node: 22 entry above does not create a tenth job, it gives the existing Node 22 job an experimental variable. An include entry introducing a new value, like node: 23, appends a new job.

A common pattern built on this is marking some combinations as allowed to fail:

    continue-on-error: ${{ matrix.experimental || false }}

fail-fast

By default, fail-fast is true: the moment one matrix job fails, all the others are cancelled.

This is the right default for a pull-request gate, because you already know the answer is no and there is no reason to spend nine jobs' worth of minutes confirming it.

It is the wrong default when you want the full picture, for example on a nightly compatibility run where "which versions broke" is the actual question:

    strategy:
      fail-fast: false
      matrix:
        node: [18, 20, 22]

With fail-fast: false every combination runs to completion and you get a complete pass/fail grid.

max-parallel

max-parallel limits how many matrix jobs run at once:

    strategy:
      max-parallel: 2
      matrix:
        node: [18, 20, 22]

Two reasons to use it. First, your account has a concurrency limit, and a wide matrix can consume the whole allowance so that other workflows queue behind it. Second, the jobs may contend over a shared external resource: three jobs writing to one test database will produce flakiness that looks like a code problem.

Matrices from a File

Hardcoding the matrix means editing the workflow to change what is tested. For anything that changes often, generate it:

jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.build.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
      - id: build
        run: echo "matrix=$(jq -c . .github/versions.json)" >> "$GITHUB_OUTPUT"

  test:
    needs: setup
    strategy:
      matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
    runs-on: ubuntu-latest
    steps:
      - run: echo "Testing ${{ matrix.node }}"

fromJSON is what makes this work: the matrix must be an object, and a job output is always a string, so the string has to be parsed. The JSON needs to be compact, hence jq -c, because a multi-line value does not survive $GITHUB_OUTPUT without the heredoc delimiter syntax.

Naming the Jobs

By default a matrix job is named after its combination, which becomes unreadable with several dimensions. name accepts expressions:

    name: test (node ${{ matrix.node }}, ${{ matrix.os }})

This matters beyond tidiness: required status checks are matched by job name, so a matrix whose names change when you add a dimension will break branch protection rules that referenced the old names.

Keeping It Under Control

Three questions worth asking of any matrix.

Does every combination tell you something different? Testing Node 18, 20 and 22 on three operating systems assumes platform-specific breakage is likely. For most pure-JavaScript libraries it is not, and Linux across three Node versions plus one Windows job at the current version catches nearly everything for a third of the cost.

Is this running on every push? A wide compatibility matrix belongs on a schedule or on release branches. The pull-request gate wants to be fast.

Are the slow combinations the ones you need? macOS runners are both the most expensive and usually the slowest to start. If the macOS job exists to catch a class of bug that has never occurred in your project, it is costing time for reassurance rather than information.

Frequently Asked Questions

How many matrix jobs can one workflow run?

The limit is 256 jobs per workflow run from a matrix. Hitting it usually means the matrix is generated and the generator is producing more combinations than intended.

Why did my other matrix jobs get cancelled?

fail-fast defaults to true, so one failure cancels the rest. Set fail-fast: false when you want every combination to report.

Can matrix jobs share state?

No more than any other jobs. Each matrix combination is a separate job on a separate runner. Passing results out means artifacts for files or job outputs for values, and a matrix job's outputs are awkward to consume because there are several of them; uploading per-combination artifacts and merging afterwards is usually cleaner.

How do I run a step only for one matrix combination?

An if on the step, for example if: matrix.os == 'ubuntu-latest'. This is the normal way to upload coverage exactly once rather than from every combination.

Does the cache work correctly across a matrix?

Only if the cache key includes the matrix dimensions. A key without matrix.node in it will restore the wrong version's dependencies into every job, which is covered in the caching lesson.

Continue Learning

Explore Related Topics

Try the Tool

When it breaks

Related Resources