The issue opens pre-filled with this page title and URL.
Imagine you have to test your application on three operating systems and three
Node.js versions. That is nine combinations. Without the right tool, you would
copy the same code nine times. The matrix strategy solves that elegantly.
What you will learn
Write a first matrix that multiplies a job across several versions or systems
Understand how GitHub computes the combinations and names the jobs it produces
Add or remove combinations with include and exclude
Control execution with fail-fast and max-parallel
Avoid an explosion in the number of jobs thanks to the page's golden rules
What is a matrix, concretely?
A matrix describes axes of variation, and GitHub generates one job per
combination. You write the job definition once, and the platform takes
care of the duplication. The
official documentation
details every key of strategy.matrix.
The number of generated jobs is calculated, not guessed. Knowing how to predict
it avoids the unpleasant surprise of a workflow suddenly starting forty runs.
GitHub computes the cartesian product of every axis. Do not let the
mathematical term put you off: it simply means all the possible combinations
of your value lists.
Picture a restaurant menu: with 2 starters and 3 mains, you can compose
2 × 3 = 6 different menus. That is exactly the cartesian product.
With a two-axis matrix:
Axis 1: [A, B] ┐
├──→ Combinations: [A,X], [A,Y], [B,X], [B,Y]
Axis 2: [X, Y] ┘
2 × 2 = 4 jobs
How do you read this? GitHub takes each value of the first axis (A, then B)
and pairs it with each value of the second (X, then Y). The result: four
combinations, so four parallel jobs.
With three axes the principle holds, you simply multiply the possibilities:
os: [ubuntu, windows] 2 values
node: [18, 20, 22] 3 values
database: [postgres, mysql] 2 values
─────────
2 × 3 × 2 = 12 jobs
In concrete terms: every combination of OS, Node version and database gets
tested. Ubuntu with Node 18 and Postgres, Ubuntu with Node 18 and MySQL, Ubuntu
with Node 20 and Postgres, and so on up to the twelfth.
The matrix variables
Every value defined in the matrix becomes reachable through the matrix
context:
strategy:
matrix:
fruit: [apple, banana]
colour: [red, yellow]
# Inside the steps you can use:
# ${{ matrix.fruit }} → "apple" or "banana"
# ${{ matrix.colour }} → "red" or "yellow"
Include: adding or enriching combinations
The include keyword is a bonus for your matrix. It does two things:
Add combinations that do not exist in the cartesian product
Enrich existing combinations with extra variables
Case 1: adding a special combination
Say you want to test Node 22, but only on Ubuntu, because it is experimental
elsewhere:
strategy:
matrix:
os: [ubuntu-24.04, windows-2025]
node: [18, 20]
include:
# This combination DOES NOT EXIST in the cartesian product
# (node 22 is not in the original list)
- os: ubuntu-24.04
node: 22
experimental: true# A bonus variable for this combination
Without include: 2 × 2 = 4 combinations.
With include: 4 + 1 = 5 combinations.
The four automatic combinations (ubuntu-24.04 and windows-latest crossed with Node 18 and 20) receive a fifth combination added by include, ubuntu-24.04 with Node 22 and the variable experimental: true, giving five jobs.Automatic combinations (Cartesian product)Combination added by includeTotal: 4 automatic combinations + 1 added = 5 jobsOSNodeExtra variablesubuntu-24.0418-ubuntu-24.0420-windows-latest18-windows-latest20-ubuntu-24.0422experimental: true+ include
Sometimes you need variables that differ per combination. The default shell,
for instance, varies with the operating system:
strategy:
matrix:
os: [ubuntu-24.04, windows-2025, macos-15]
include:
# These lines ENRICH the existing combinations
# by adding a "shell" variable
- os: windows-2025
shell: pwsh# PowerShell on Windows
- os: ubuntu-24.04
shell: bash# Bash on Ubuntu
- os: macos-15
shell: bash# Bash on macOS
runs-on: ${{ matrix.os }}
defaults:
run:
shell: ${{ matrix.shell }}# Uses the right shell
Case 3: a matrix built only from include
You can build a matrix without axes, purely from include. That is useful
for configurations that differ wildly:
strategy:
matrix:
include:
- name: 'Production EU'
region: 'eu-west-1'
env: 'prod'
replicas: 3
- name: 'Production US'
region: 'us-east-1'
env: 'prod'
replicas: 3
- name: 'Staging'
region: 'eu-west-1'
env: 'staging'
replicas: 1
# Three jobs with completely custom configurations
Exclude: removing unwanted combinations
exclude does the opposite of include: it removes combinations from the
cartesian product. It is a way of saying "I want everything except this".
Why exclude combinations?
A few common reasons:
a version is not supported on a particular operating system;
a combination is redundant or pointless;
you want to save CI minutes on irrelevant tests.
A concrete example
strategy:
matrix:
os: [ubuntu-24.04, windows-2025, macos-15]
node: [18, 20, 22]
exclude:
# Node 22 has known problems on Windows
- os: windows-2025
node: 22
# We do not test every version on macOS (expensive)
- os: macos-15
node: 18
Counting the jobs:
cartesian product: 3 × 3 = 9 combinations
exclusions: minus 2 combinations
total: 7 jobs
Controlling execution: fail-fast and max-parallel
By default, a matrix launches everything in parallel and stops at the first
failure. These two settings arbitrate between speed of feedback and the amount
of information gathered in a single run.
fail-fast: stop everything, or carry on?
By default, fail-fast is true. That means if a single job of the matrix
fails, GitHub cancels every other one immediately.
With fail-fast: true, job 2 failing cancels jobs 3, 4 and 5; with fail-fast: false, every job runs to the end and both failures out of five, jobs 2 and 5, are visible.fail-fast: true (default)fail-fast: falseJob 1 (ubuntu/Node 18): passedJob 2 (ubuntu/Node 20): failedJob 3 (ubuntu/Node 22): cancelledJob 4 (windows/Node 18): cancelledJob 5 (windows/Node 20): cancelled3 jobs cancelled on failureJob 1 (ubuntu/Node 18): passedJob 2 (ubuntu/Node 20): failedJob 3 (ubuntu/Node 22): passedJob 4 (windows/Node 18): passedJob 5 (windows/Node 20): failed2 failures out of 5 (all seen)
you are debugging and looking for which combinations fail;
the jobs are independent.
strategy:
fail-fast: false# Carry on even when a job fails
matrix:
node: [18, 20, 22]
max-parallel: limiting simultaneous jobs
By default, GitHub launches every job in parallel. With max-parallel, you
can cap that number:
strategy:
max-parallel: 2# At most 2 jobs at a time
matrix:
node: [18, 20, 22] # 3 jobs in total
Why limit parallelism?
Situation
Reason
Tests sharing a database
Avoid data conflicts
An external API with rate limiting
Stay under the quota
Limited self-hosted runners
Avoid saturation
Saving minutes
Reduce costs (private repositories)
Swipe to see more
With max-parallel: 2, five jobs run in three successive batches: jobs 1 and 2, then 3 and 4, then 5.Batch 1 (2 jobs)Batch 2 (2 jobs)Batch 3 (1 job)Job 1Job 2Job 3Job 4Job 5timetime
These constructs matter when the matrix values are unknown at writing time, or
when they depend on the run context.
A dynamic matrix with fromJSON
Sometimes you do not know the matrix values in advance. You may want to test
only the modules that changed, for instance. The answer is to generate the
matrix dynamically in a first job.
These five reflexes separate a readable, thrifty matrix from one that saturates
the runners for reasons nobody can explain.
1. Name your jobs explicitly
Without a custom name, GitHub shows "test (1)", "test (2)", which helps nobody.
jobs:
test:
# ✅ An explicit name carrying the matrix values
name: Test ${{ matrix.os }} / Node ${{ matrix.node }}
strategy:
matrix:
os: [ubuntu-24.04, windows-2025]
node: [18, 20]
The result in the interface:
✓ Test ubuntu-24.04 / Node 18 (2m 15s)
✓ Test ubuntu-24.04 / Node 20 (2m 08s)
✓ Test windows-2025 / Node 18 (3m 45s)
✓ Test windows-2025 / Node 20 (3m 38s)
2. Use fail-fast: false while debugging
When you are hunting for which combinations fail, you want every result:
strategy:
fail-fast: false# Do not cancel the other jobs on failure
matrix:
node: [18, 20, 22]
3. Keep your matrix small
# ❌ AVOID: 3 × 4 × 3 = 36 combinations!
matrix:
os: [ubuntu, windows, macos]
node: [16, 18, 20, 22]
database: [postgres, mysql, sqlite]
# ✅ PREFER: targeted combinations
matrix:
include:
# Full test on Ubuntu (the reference)
- os: ubuntu-24.04
node: 20
database: postgres
# Windows validation
- os: windows-2025
node: 20
database: postgres
# Backward-compatibility test
- os: ubuntu-24.04
node: 18
database: mysql
4. Handle the specifics of each OS
Windows and Linux do not use the same commands. Use include to customise:
strategy:
matrix:
os: [ubuntu-24.04, windows-2025]
include:
- os: ubuntu-24.04
script: ./scripts/test.sh
shell: bash
- os: windows-2025
script: .\scripts\test.ps1
shell: pwsh
steps:
- run: ${{ matrix.script }}
shell: ${{ matrix.shell }}
5. Document your exclusions
Exclusions can look mysterious to contributors. Always add a comment:
exclude:
# Python 3.9 reached end of life (October 2025)
# We only keep it on Ubuntu for legacy systems
- python: '3.9'
os: macos-15
- python: '3.9'
os: windows-2025
# Node 22 has a known bug on Windows Server
# See https://github.com/nodejs/node/issues/XXXXX
- node: 22
os: windows-2025
Key points
Concept
Syntax
Usage
Matrix axes
matrix: { os: [...], node: [...] }
Define the combinations
Reading values
${{ matrix.os }}
Use them inside the steps
Add or enrich
include: [...]
Special cases
Exclude
exclude: [...]
Remove combinations
Carry on after failure
fail-fast: false
Debugging
Cap parallelism
max-parallel: N
Limited resources
Dynamic matrix
fromJSON(...)
Computed values
Swipe to see more
Check your understanding
Make sure the essentials of this guide are yours. The questions only
cover what was explained here.
Knowledge check
Check what you have learned with this interactive quiz
6 questions
6 min
70% to pass
How it works
The timer starts when you click Start
Multiple choice, true/false and short answer questions
You can move between questions
Detailed results are shown at the end
Starts the quiz and the timer
⏱️--:--
0 / 6
Review
(0/0)
--:--
0%
0Correct
0To review
0:00Time
-Level
Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.