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: falsefor 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:
| Matrix | Jobs | Cost per run |
|---|---|---|
| 3 OSes x 4 Node versions, all combinations | 12 | 4 x (10 x 0.006 + 10 x 0.010 + 10 x 0.062) = $3.12 |
| Linux x 4 versions, plus Windows and macOS on latest | 6 | 4 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
mainand 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