# GitHub Actions Matrix Builds That Don't Waste Minutes

URL: https://www.devopsness.com/blog/github-actions-matrix-builds
Author: Kiril Urbonas
Published: 2026-07-23
Updated: 2026-07-23
Categories: DevOps
Tags: CI/CD, GitHub Actions, DevOps, Testing, Matrix

Summary: 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 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
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
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
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
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
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
      - 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](/blog/github-actions-recipes) pillar, and if runtime is your real problem, [how to speed up a slow CI pipeline](/blog/speed-up-ci-pipeline).

