Skip to content

GitHub Actions Matrix Builds: Complete Guide

Lesson 1 of 11Intermediate4 min readGitHub Actions & CI/CD · Advanced ActionsVerified: GitHub-hosted runners and matrix syntax, August 2026

A matrix turns one job definition into many jobs. That is the whole feature, and it is worth being precise about the word job: each combination is a separate job, on its own runner, with its own filesystem, and it shares nothing with its siblings.

strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: ["20", "22", "24"]

Two axes of three values produce nine jobs — every combination. Adding a fourth Node version makes it twelve; adding a fourth OS makes it sixteen. The growth is multiplicative, and it is the main way organisations discover their concurrency limits.

Each value is available as matrix.<name>:

runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}

Quote version numbers. node: [20, 22, 24] gives integers, which mostly works; python: [3.9, 3.10] gives 3.9 and 3.1, which does not. See YAML syntax.

include is the most useful and most misunderstood key in the strategy block. It does two different things depending on whether its entry matches an existing combination.

Adding properties to matching combinations:

strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: ["22", "24"]
include:
- os: ubuntu-latest
node: "24"
coverage: true

That produces the same four jobs, with matrix.coverage set on one of them. Nothing is added.

Adding a whole new combination:

strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: ["22", "24"]
include:
- os: macos-latest
node: "24"

macos-latest is not a value of the os axis, so this creates a fifth job rather than expanding the product.

The rule: an include entry that can be matched against existing combinations extends them; one that cannot appends a new job.

include with no axes at all is the pattern for “these exact combinations and no others”:

strategy:
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: arm64

Three jobs, not the six a goos × goarch product would give. This is usually what people want when they write a matrix and are surprised by the count.

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

Eight jobs instead of nine. exclude is applied to the product first, then include is applied — so an include can add back something exclude removed, which is confusing enough that it is worth avoiding deliberately.

strategy:
fail-fast: false
matrix:
node: ["20", "22", "24"]

The default is true: the first failing job cancels every other job in the matrix. That saves runner minutes and destroys the information you built the matrix to get. “It failed on some version” is not a useful bug report; “it passed on 22 and 24 and failed on 20” is a diagnosis.

Set fail-fast: false for compatibility matrices. Leave it true when the legs are genuinely redundant — several shards of the same test suite, where one shard failing means the commit is bad regardless.

strategy:
max-parallel: 2
matrix:
environment: [dev, staging, sandbox]

Caps how many legs run at once. The reason is almost never runner cost — it is that the jobs share an external resource. Three legs hitting one rate-limited API, or one test database, will fail in ways that look like flakiness. max-parallel: 1 serialises entirely.

A matrix can be generated at runtime from a job output, which is how you build “test every package that changed”:

jobs:
discover:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.list.outputs.packages }}
steps:
- uses: actions/checkout@v7
- id: list
run: |
packages="$(ls -d packages/*/ | xargs -n1 basename | jq -R . | jq -sc .)"
echo "packages=${packages}" >> "$GITHUB_OUTPUT"
test:
needs: discover
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package: ${{ fromJSON(needs.discover.outputs.packages) }}
steps:
- run: echo "testing ${{ matrix.package }}"

What it doesEmits a JSON array from one job and consumes it as the matrix of a dependent job.

Why we run itThe set of things to test is often data — changed directories, discovered packages, environments from a config file — rather than a list a human maintains in the workflow file.

Expected resultOne job per array element, with matrix.package holding the value.

fromJSON is required — job outputs are strings, and the matrix key needs a real array.

Two failure modes to plan for. An empty array produces zero jobs, and a required check that generates zero jobs never reports, so the pull request waits forever; guard with a fallback value or an if: on the dependent job. And the generating step is building a matrix from repository data, so if that data can come from a pull request title or branch name, you have an injection path into your workflow definition.

Job names are what branch protection matches, and a matrix changes them:

jobs:
test:
name: Test (Node ${{ matrix.node }})

produces Test (Node 20), Test (Node 22), Test (Node 24). Without an explicit name, GitHub generates test (20), test (22), test (24).

The aggregating job is the pattern worth adopting:

all-tests:
if: always()
needs: test
runs-on: ubuntu-latest
steps:
- name: Fail if any matrix leg failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1

Require all-tests in branch protection. Its name is stable, so the matrix can change freely. if: always() is essential — without it the job is skipped when the matrix fails, and a skipped required check does not block.

strategy:
matrix:
include:
- os: ubuntu-24.04
arch: x64
- os: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.os }}

runs-on accepts an expression, so the matrix can choose the machine. This is how a native multi-architecture build works without emulation — see ARM runners.

  1. Write a matrix with two axes of three values. Confirm nine jobs appear in the run.

  2. Add an include entry that matches an existing combination and sets a new property. Confirm the job count is still nine.

  3. Add an include entry with a value not present on any axis. Confirm the count becomes ten.

  4. Make one leg fail. With the default fail-fast, watch the others get cancelled. Set fail-fast: false and confirm they all report.

  5. Add the all-tests aggregating job. Confirm it fails when a leg fails, and that it still runs when legs are cancelled.

  6. Build a dynamic matrix from fromJSON. Then make the generating step emit [] and observe what happens to the dependent job — this is the failure mode to design around.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Want production-ready workflow templates? The Professional Toolkit has five, with permissions set correctly.