A practical guide to matrix builds in GitHub Actions that tests across versions and platforms without burning your monthly minutes.
A matrix build runs the same job across a set of combinations. Instead of copying a "test on Node 18," "test on Node 20," and "test on Node 22" job three times, you declare the dimensions once and GitHub Actions fans them out into parallel jobs for you. It is the single biggest lever for testing broadly, and if you are not careful, it is also the fastest way to drain your minutes.
The matrix lives under strategy.matrix. Every key you add becomes a dimension, and GitHub multiplies the dimensions together to produce one job per combination.
name: test
on: [push, pull_request]
jobs:
test:
strategy:
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
name: node ${{ matrix.node }} on ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
Three Node versions times two operating systems is six jobs. That number matters, because macOS runners bill at ten times the rate of Linux runners. Six jobs where half run on macOS is not "six units of cost," it is closer to thirty-three. Keep that multiplier in your head every time you add a dimension.
Notice the name field. By default GitHub labels matrix jobs with the raw combination, which gets unreadable once you have three or more dimensions. Setting an explicit name makes the checks list scannable and makes a red X point straight at the failing combination.
You rarely want the full cartesian product. include adds specific combinations or extra properties, and exclude trims ones you do not care about.
strategy:
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
exclude:
# Windows on old Node isn't a supported combo
- os: windows-latest
node: 18
include:
# Test one bleeding-edge combo without expanding the whole grid
- os: ubuntu-latest
node: 23
experimental: true
The mental model is worth getting right. exclude removes matching combinations from the generated product. include is additive: if its properties match an existing combination it merges extra keys into it, otherwise it appends a brand new job. The experimental: true flag above is a common pattern, because you can pair it with continue-on-error: ${{ matrix.experimental }} so a failure on the bleeding-edge combo does not fail the whole run.
Reach for include when you want to cover one important edge case rather than a full extra row. Adding node: 23 as a full dimension would double your jobs. Adding it through include costs exactly one.
By default fail-fast is true, which means the moment any matrix job fails, GitHub cancels every other in-flight job in that matrix. This is the right default for pull request checks. If Node 18 is already broken, you usually do not need to spend minutes confirming that Node 20, 22, macOS, and Windows are also affected. Cancel early, save the runners, fix the problem.
Set it to false when you genuinely want the full picture:
strategy:
fail-fast: false
matrix:
python: ['3.10', '3.11', '3.12']
os: [ubuntu-latest, macos-latest]
The classic case is a compatibility sweep where you need to know exactly which versions pass and which fail. With fail-fast: true a single early failure hides the status of everything else, so you fix one thing, rerun the whole matrix, and discover the next failure. With fail-fast: false you get one complete report. The tradeoff is minutes: a fully broken change runs every job to completion. A reasonable rule is fail-fast: true on PRs and false on scheduled nightly compatibility runs.
max-parallel limits how many matrix jobs run at once. It does not change how many jobs exist, only how many execute simultaneously.
strategy:
max-parallel: 2
matrix:
shard: [1, 2, 3, 4, 5, 6]
Two reasons to use it. First, cost smoothing on self-hosted or metered runners where you do not want twenty jobs slamming a shared resource, like a test database or a rate-limited external API. Second, staying under a plan's concurrency ceiling so your matrix does not starve every other workflow in the org. The cost of capping is wall-clock time, since fewer parallel jobs means the matrix takes longer end to end. It trims peak load, not total minutes.
Hardcoding the matrix is fine until the list of things to test changes on its own, for example one job per changed package in a monorepo. You can generate the matrix in one job and feed it to the next with fromJSON.
jobs:
discover:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: set
run: |
pkgs=$(ls packages | jq -R -s -c 'split("\n")[:-1]')
echo "matrix={\"package\":$pkgs}" >> "$GITHUB_OUTPUT"
test:
needs: discover
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.discover.outputs.matrix) }}
runs-on: ubuntu-latest
name: test ${{ matrix.package }}
steps:
- uses: actions/checkout@v4
- run: npm test --workspace packages/${{ matrix.package }}
The discover job builds a JSON object, writes it to GITHUB_OUTPUT, and the test job parses it back into a matrix with fromJSON. This is how you test only what changed instead of everything every time, which is one of the biggest minute savers available. Combine it with a diff filter so the discover step emits only packages touched in the pull request.
This is the minutes trap. Each new dimension multiplies, it does not add. Four Node versions, three operating systems, and two database engines is not nine jobs, it is twenty-four, and every push runs all of them. Some habits that keep it sane:
Caching interacts with the matrix in a way that bites people. If your cache key does not include the matrix dimension, every combination fights over one cache entry, and you get corrupted restores or constant misses. Put the dimension in the key.
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ matrix.os }}-node${{ matrix.node }}-${{ hashFiles('package-lock.json') }}
The actions/setup-node and similar setup actions handle this internally when you enable their built-in cache, but any hand-rolled cache step needs the dimension baked into the key. Otherwise the Node 18 job restores the Node 22 job's node_modules and you spend an afternoon chasing a phantom bug.
Start narrow. A basic version-plus-OS matrix with fail-fast: true covers most projects and keeps minutes predictable. Reach for exclude and include to shape the grid before you reach for a new dimension, since each dimension multiplies your bill. Turn fail-fast off only for scheduled compatibility sweeps, use max-parallel when a shared resource or plan ceiling forces it, and graduate to a dynamic fromJSON matrix once "test everything on every push" starts costing real money. Always fold the matrix dimension into your cache keys. Do that, and a matrix becomes what it should be: broad coverage that stays cheap.
For more patterns, see our GitHub Actions recipes pillar, and if runtime is your real problem, how to speed up a slow CI pipeline.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A production-focused Argo CD and GitOps guide: declarative Applications and ApplicationSets, app-of-apps, sync waves and hooks, automated self-heal and prune, progressive delivery with Argo Rollouts, projects/RBAC, and secure secrets — with copy-paste examples.
Most GitHub Actions pain comes from the same handful of jobs done wrong. This is the map: the recipes that make pipelines fast, secure, and cheap.
Explore more articles in this category
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
A prioritized toolkit for cutting CI time: measure the critical path first, then cache, parallelize, run only what changed, and shrink the work itself.
Stop stashing long-lived AWS access keys in GitHub secrets and let OIDC hand your workflows short-lived, scoped credentials instead.
Evergreen posts worth revisiting.