Testing every supported platform and version by hand is impossible; a matrix does it in parallel from a few lines.
strategy.matrix turns one job into many. Each key is a variable with a list of values, and GitHub creates one job per combination, reading values as ${{ matrix.<key> }}.
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [22, 24]That is 3 x 2 = 6 jobs. Fine-tuning:
include: add extra combinations or add keys to matching ones (for example anexperimental: trueflag).exclude: drop specific combinations.fail-fast(defaulttrue): cancel the remaining matrix jobs when one fails. Setfalseto see every failure.max-parallel: limit how many run at once.
A matrix can generate up to 256 jobs per workflow run. Name the job with matrix values, name: test (${{ matrix.os }}, node ${{ matrix.node }}), so failures are readable. If branch protection requires a check, it must name the exact job names produced by the matrix.
The matrix can also be dynamic: one job computes a JSON list and outputs it, and the next uses matrix: ${{ fromJSON(needs.plan.outputs.targets) }}, which is how monorepos test only the packages that changed.
Example
jobs:
test:
name: test (${{ matrix.os }}, py ${{ matrix.python }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
python: ["3.13", "3.14"]
exclude:
- os: macos-latest
python: "3.13"
include:
- os: ubuntu-latest
python: "3.15"
experimental: true
continue-on-error: ${{ matrix.experimental == true }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with: {python-version: "${{ matrix.python }}"}
- run: python -m pytest # 4 jobs in totalRemember
- Each matrix combination is a separate parallel job
- include adds or extends combinations; exclude removes them
- fail-fast: false shows every failing combination
Watch out for
- Unquoted versions like 3.10 become the number 3.1 in YAML.