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.
The Cartesian product
Section titled “The Cartesian product”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: adding without multiplying
Section titled “include: adding without multiplying”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: trueThat 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: arm64Three 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.
exclude: removing from the product
Section titled “exclude: removing from the product”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.
fail-fast
Section titled “fail-fast”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.
max-parallel
Section titled “max-parallel”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.
Dynamic matrices
Section titled “Dynamic matrices”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.
Matrix values appear in the job name
Section titled “Matrix values appear in the job name”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 1Require 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.
Runner selection from the matrix
Section titled “Runner selection from the matrix”strategy: matrix: include: - os: ubuntu-24.04 arch: x64 - os: ubuntu-24.04-arm arch: arm64runs-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.
Exercise
Section titled “Exercise”-
Write a matrix with two axes of three values. Confirm nine jobs appear in the run.
-
Add an
includeentry that matches an existing combination and sets a new property. Confirm the job count is still nine. -
Add an
includeentry with a value not present on any axis. Confirm the count becomes ten. -
Make one leg fail. With the default
fail-fast, watch the others get cancelled. Setfail-fast: falseand confirm they all report. -
Add the
all-testsaggregating job. Confirm it fails when a leg fails, and that it still runs when legs are cancelled. -
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.