Matrix Builds
A matrix turns one job definition into many. Declare a few axes — operating systems and language versions, say — and Actions runs the Cartesian product as separate, parallel jobs: three OSes times four versions is twelve jobs from a dozen lines of YAML. It is how you prove a library works everywhere it claims to, without copy-pasting a job per combination.
The controls around the grid are what make it practical: include and exclude trim or extend the set, fail-fast decides whether one failure cancels the rest, and max-parallel caps how many run at once.
The Matrix Grid
List multiple keys under strategy.matrix and Actions expands every combination into its own job. Reference the values with ${{ matrix.<key> }} — most often matrix.os in runs-on: so a single block targets every platform.
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci && npm test
include and exclude
These two read as opposites and are constantly confused. exclude is the filter: it removes specific combinations from the generated grid, such as a runtime that does not build on an old OS. include does the reverse — it adds variables to combinations that already match, or appends entirely new combinations the product would not have produced.
A variable that only an include entry sets will be empty in every other job. If you read matrix.extra in a step, make sure every combination defines it, or guard the step with an if:.
fail-fast
The default is fail-fast: true, which cancels every in-progress matrix job the instant one fails. That is exactly wrong when you are trying to learn which versions broke, because the first failure hides the rest. Set fail-fast: false on test matrices so every combination reports its own result.
max-parallel
A wide matrix can spawn dozens of jobs at once, exhausting your concurrency limit or hammering an external API's rate limit. max-parallel caps how many matrix jobs run simultaneously, trading some wall-clock time for staying inside self-hosted capacity or third-party quotas.
Consuming Matrix Values
Beyond runs-on:, matrix values flow into any step expression. For data-driven sets, a prior job can emit a JSON array as an output and a later job can build its matrix from it with fromJSON() — so the combinations are computed at runtime rather than hardcoded.
Keeping the varying values in matrix.* and referencing them everywhere is what lets one job definition cover the whole grid instead of duplicating near-identical jobs.
- Leaving
fail-fastat its defaulttrueon a test matrix, so the first failing combination cancels the others and hides which versions broke. - Defining a 50-job matrix with no
max-parallel, exhausting the concurrency limit and leaving jobs queued for a long time. - Using
includeexpecting it to remove combinations — it adds them;excludeis the filter. - Hardcoding runtime versions in the matrix and never updating them, so the build quietly stops covering current releases.
- Reading
matrix.xin a step wherexis only set by anincludethat does not apply, getting an empty value.
- Set
fail-fast: falseon test matrices when you want the complete picture of which combinations fail. - Use
max-parallelto stay within self-hosted runner capacity or external API rate limits. - Use
excludeto drop known-incompatible pairs, such as an old runtime on a newer OS. - Generate the matrix dynamically from a prior job's JSON output with
fromJSON()when the set is data-driven. - Keep varying values in
matrix.*and reference them inruns-on:and steps to avoid duplicating job definitions.
parallel:matrixCircleCI matrix jobsAzure Pipelines strategy: matrixJenkins declarative matrix directiveKnowledge Check
What is the difference between include and exclude in a matrix?
excluderemoves combinations;includeadds variables to matches or appends new ones- Both filter the grid down to fewer combinations;
includeis just the newer spelling ofexclude includeremoves combinations from the grid andexcludeappends new ones- They only reorder the sequence jobs run in, not the set of combinations
You want to see every failing version, not just the first. What do you set?
fail-fast: falsefail-fast: truemax-parallel: 1continue-on-error: trueon the matrix
Why does max-parallel matter with self-hosted runners?
- It caps simultaneous matrix jobs so the grid stays within your finite runner capacity
- It speeds the grid up by running more matrix combinations at once than the default
- It is mandatory for any matrix that defines more than two separate axes
- It changes which runner labels in the pool are eligible to pick up jobs
How is a dynamic matrix built from another job's output?
- A job emits a JSON array output and a later job passes it through
fromJSON()intomatrix - The matrix automatically inherits any prior job's exported environment variables at runtime
- You list every combination by hand under the
includekey in the matrix - Dynamic matrices computed at runtime are not supported in Actions
You got correct