GitHub Actions Matrix Builds That Don't Waste 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 basic syntax #

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.

yaml.yaml

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.

include and exclude #

You rarely want the full cartesian product. include adds specific combinations or extra properties, and exclude trims ones you do not care about.

yaml.yaml

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.

fail-fast: true vs false #

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:

yaml.yaml

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 to cap concurrency #

max-parallel limits how many matrix jobs run at once. It does not change how many jobs exist, only how many execute simultaneously.

yaml.yaml

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.

Dynamic matrices with fromJSON #

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.

yaml.yaml

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.

Avoiding combinatorial explosion #

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:

Full grid nightly, thin grid on PRs. Run the exhaustive matrix on a schedule and a trimmed one on pull requests.

Prune with exclude. Drop combinations nobody ships, like the oldest runtime on the newest OS.

Prefer include for edge cases. One extra combination should not become a full extra dimension.

Watch the runner multiplier. Push most jobs to Linux and reserve macOS and Windows for the combinations that actually behave differently there.

Matrix and caching #

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.

yaml.yaml

- 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.

The call we'd make #

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.