Skip to content
compiler.dev

GitHub Actions Matrix Builds Done Right

A build matrix runs the same job across combinations of OS, language version or other variables. It is the standard way to test a library on several runtimes, and also an easy way to triple your bill without noticing. This guide covers the syntax, the options that matter and the cost controls.

The basics

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node: [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 creates 2 x 2 = 4 jobs. GitHub limits a matrix to 256 jobs per workflow run. Paste yours into the matrix expander to see every combination before you push, and what it costs.

include and exclude

exclude removes combinations; include adds or extends them.

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    node: [20, 22]
    exclude:
      - os: macos-latest
        node: 20
    include:
      - os: ubuntu-latest
        node: 24
        experimental: true

Two rules trip people up. First, include entries are processed after exclude. Second, an include entry whose keys match an existing combination adds extra values to it (without overwriting original values); one that matches nothing becomes a new job. Add a distinct key (like experimental above) and read it with ${{ matrix.experimental }}.

fail-fast and max-parallel

By default fail-fast is true: when one job fails, GitHub cancels the others still running. That saves minutes but hides information, since you only learn about one failing combination. Use:

  • fail-fast: false for libraries and compatibility matrices, where you want the full picture.
  • fail-fast: true (the default) for matrices of shards of one suite, where any failure means the run is lost anyway.
strategy:
  fail-fast: false
  max-parallel: 4

max-parallel limits concurrent jobs from the matrix, useful when jobs share a resource such as a test database or API rate limit, or to leave concurrency for other workflows. The default runs as many as the plan allows.

Mark a combination as allowed to fail with continue-on-error: ${{ matrix.experimental == true }} at the job level.

Keep the bill under control

Matrix size multiplies billed minutes, and the OS you choose multiplies the price. Per GitHub's runner pricing, a Windows minute is $0.010 and macOS $0.062 against $0.006 for Linux, and in included minutes Windows counts double and macOS tenfold. Compare these two matrices, each at 10 minutes per job:

MatrixJobsCost per run
3 OSes x 4 Node versions, all combinations124 x (10 x 0.006 + 10 x 0.010 + 10 x 0.062) = $3.12
Linux x 4 versions, plus Windows and macOS on latest64 x 0.06 + 0.10 + 0.62 = $0.96

Use the Actions cost calculator to test your own. Ways to shrink a matrix without losing real coverage:

  • Test the edges, not every cell. Oldest and newest supported versions catch most compatibility bugs. Test the full set on a schedule instead of every push.
  • Heavy OSes once. Run Windows and macOS on one version of the language.
  • Two tiers. A small matrix on PRs; the full matrix on main and nightly.
  • Skip by changed paths so a docs change runs nothing.
  • Cancel superseded runs with concurrency, which matters more as the matrix grows.

Dynamic matrices

Generate the matrix in a first job when it depends on the repository state, for example only the changed packages in a monorepo:

jobs:
  plan:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
      - id: set
        run: echo "matrix=$(node scripts/affected.mjs)" >> "$GITHUB_OUTPUT"
  build:
    needs: plan
    if: ${{ needs.plan.outputs.matrix != '[]' }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        package: ${{ fromJSON(needs.plan.outputs.matrix) }}
    steps:
      - run: npm run build -w ${{ matrix.package }}

If the generated matrix is empty, the job fails with an error, which is why the if guard is there. See also monorepo CI with Nx or Turborepo.

Required checks and matrices

Branch protection matches check names. Matrix jobs get names like test (ubuntu-latest, 20), and any change to the matrix changes the names, leaving required checks stuck at "Expected". Name the matrix jobs explicitly if you must require them, or better, add one summary job that depends on the whole matrix and require only that:

  all-tests:
    if: always()
    needs: test
    runs-on: ubuntu-latest
    steps:
      - run: test "${{ needs.test.result }}" = "success"

Use if: always() so the summary job runs and fails when the matrix fails; otherwise it is skipped, and a skipped job can satisfy a required check. More on this in GitHub merge queues.

Using matrix values safely

Matrix values are expressions too. If any come from untrusted input, such as a PR label, pass them through env rather than into scripts; see securing GitHub Actions. Also quote versions in YAML: python: [3.9, 3.10] parses 3.10 as the number 3.1. Write ["3.9", "3.10"].

Cache keys per cell

Include matrix values in cache keys so cells do not overwrite each other:

key: npm-${{ matrix.os }}-node${{ matrix.node }}-${{ hashFiles('package-lock.json') }}

Many cells writing large caches can fill the 10 GB repository limit. See the caching guide.

FAQ

What is the maximum matrix size?

256 jobs per workflow run, according to GitHub's usage limits.

Can a matrix include jobs on different runners?

Yes, set runs-on: ${{ matrix.os }} or include a runner key in each entry. Larger-runner labels work too.

How do I name matrix jobs?

Set name: test (${{ matrix.os }}, node ${{ matrix.node }}) at the job level for readable check names.

Why does my include entry create a new job?

Because it does not match an existing combination on all the original keys. Check for typos and type differences like 20 versus "20".

Try it on your matrix

When a matrix takes long, each cell's speed matters as much as its count. compiler.dev's comparison mode runs one cell on faster machines, so you can see run time and cost per cell before changing anything.

Made by compiler.dev. Free tools · Pricing