Build the mental model
A matrix lets you write one job definition and have Actions expand it into many concrete jobs, one per combination of the variables you list. You declare strategy.matrix with named arrays, say os and node-version, and every step in that job can read the current combination through the matrix context. Six combinations become six jobs running in parallel on six separate runners, each with its own cache, checkout, and result line in the checks list.
include and exclude let you bend the grid rather than accept the full cross product. exclude removes combinations that make no sense: a Node version that never shipped for Windows, or a runtime you support only on Linux. include does two quite different things. If every key in the entry matches an expanded job, its extra keys are added to that job; if they do not all match, a brand-new job is appended instead. That dual behaviour surprises people constantly. Adding coverage: true to one existing combination decorates it; adding an unmatched os value silently creates a seventh job.
fail-fast defaults to true, which cancels every sibling the moment one combination fails. That is the right default for saving minutes and the wrong default for learning something. A failure on Node 18 with fail-fast on tells you Node 18 is broken. With fail-fast off you also learn whether 20 and 22 are broken, which is the difference between a version-specific bug and a genuinely broken commit. Set fail-fast: false whenever you are debugging.
Cost is multiplicative. Adding a third OS to a 2x3 matrix does not add three jobs, it adds three more per Node version, and every one of them bills minutes.
MATRIX EXPANDING INTO PARALLEL JOBS
-----------------------------------
strategy.matrix
os: [ubuntu-latest, windows-latest]
node-version: ['18', '20', '22']
node 18 node 20 node 22
+-------------+-------------+-------------+
ubuntu | job 1 | job 2 | job 3 |
| | | +coverage |
+-------------+-------------+-------------+
windows | EXCLUDED | job 4 | job 5 |
+-------------+-------------+-------------+
2 x 3 = 6 combinations, minus 1 exclude = 5 parallel jobs
the include entry decorated job 3, it did not add a job 6
fail-fast: true job 3 fails -> jobs 1,2,4,5 CANCELLED
fail-fast: false job 3 fails -> all five still reportConnect it to a real scenario
Suppose you ship a library that claims to support Node 18, 20, and 22 on Linux and Windows. Without a matrix you either test on one version and hope, or you copy-paste the same job five times and let the copies drift. With strategy.matrix you write the job once.
runs-on: ${{ matrix.os }} is the piece that makes each expanded job land on a different runner image. The name: expression matters more than it looks: without it every expanded job appears in the checks list under the same name and you cannot tell which one went red, which is precisely the information a matrix exists to give you.
The exclude entry drops Windows on Node 18 because you never promised that combination and paying for it teaches you nothing. The include entry attaches coverage: true to a single combination, so the coverage step runs once rather than five times and you avoid merging five conflicting reports.
While debugging, set fail-fast: false and read the whole grid before drawing a conclusion. Once the pipeline is stable on a busy repository, add max-parallel so that one pull request cannot occupy every concurrent runner your plan allows and stall everyone else's builds behind it.
Try the working example
name: Matrix tests
on:
pull_request:
workflow_dispatch:
jobs:
test:
name: node ${{ matrix.node-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest]
node-version: ['18', '20', '22']
exclude:
- os: windows-latest
node-version: '18'
include:
- os: ubuntu-latest
node-version: '22'
coverage: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Collect coverage on one combination only
if: matrix.coverage
run: npm run coverage
This definition expands into five jobs: Ubuntu with Node 18, 20, and 22, and Windows with Node 20 and 22, which is the 2x3 grid minus the excluded Windows and Node 18 entry. Each runs on its own runner in parallel and appears as its own check named by the name: expression. Only the Ubuntu and Node 22 job has matrix.coverage set, so the coverage step runs there and is skipped in the other four. Because fail-fast is false, a failure in any one combination does not cancel the others; each reports its own pass or fail, and the workflow as a whole is red if any of the five failed. With fail-fast left at its default, the first failure would cancel every sibling still running, and those combinations would report as cancelled rather than as passed or failed, so you would not learn whether the problem was version-specific.5-minute try-it
Add macos-latest to the os array of your 2x3 matrix. Before pushing, write down how many jobs you expect. Then set fail-fast: true, deliberately break a test only on Node 18, and compare which combinations report results against the same run with fail-fast: false. Finally, move an existing combination's extra flag into an include entry and confirm it decorates the existing job rather than appending a new one.
One important caution
Leaving fail-fast at its default while debugging a version-specific failure: the first red combination cancels the rest, so you never find out whether the other versions are broken too.
Adding an include entry whose keys do not all match an existing combination: instead of adding a flag to that job it silently appends an extra job, and the matrix quietly grows along with the bill.
GitHub Docs - Running variations of jobs in a workflow — CI/CD with GitHub Actions