GitHub Actions matrix strategy — parallel jobs done right
A test suite that takes 10 minutes to run serially takes 2 minutes when split across 5 parallel jobs. GitHub Actions matrix strategy does this automatically — define the dimensions, let GitHub fan out the jobs. Here is how to use it for cross-version testing, OS compatibility, and anything else you want to test in parallel.
Basic matrix — test across Node versions
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
This creates 3 parallel jobs: one for each Node version. GitHub runs them simultaneously. All three must pass for the workflow to succeed.
Multi-dimensional matrix
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20]
# Creates 6 jobs:
# ubuntu + node18, ubuntu + node20
# windows + node18, windows + node20
# macos + node18, macos + node20
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
Excludes — remove specific combinations
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20]
exclude:
# Don't bother testing Node 18 on Windows — known issues
- os: windows-latest
node-version: 18
# Skip macOS + Node 18 to reduce CI costs
- os: macos-latest
node-version: 18
# Remaining: ubuntu+18, ubuntu+20, windows+20, macos+20
Includes — add specific combinations with extra variables
strategy:
matrix:
os: [ubuntu-latest]
node-version: [18, 20]
include:
# Add a special combination with an extra variable
- os: ubuntu-latest
node-version: 20
run-integration-tests: true
# Add a combination not in the base matrix
- os: macos-latest
node-version: 22
experimental: true
steps:
- name: Run integration tests
if: ${{ matrix.run-integration-tests }}
run: npm run test:integration
fail-fast control
By default, if any matrix job fails, GitHub cancels all other jobs immediately. This is usually what you want for PRs (fast feedback), but sometimes you want to see all results:
strategy:
fail-fast: false # Let all jobs complete even if one fails
matrix:
node-version: [18, 20, 22]
# Use fail-fast: false when:
# - You want to see which versions fail and which pass
# - Running a nightly compatibility matrix
# - Debugging a flaky test across versions
Dynamic matrices from JSON
Generate the matrix from a previous step — useful when the list of things to test is determined at runtime:
jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: set-matrix
run: |
# Generate matrix from changed packages in a monorepo
PACKAGES=$(node scripts/get-changed-packages.js)
echo "matrix=${PACKAGES}" >> $GITHUB_OUTPUT
test:
needs: setup
strategy:
matrix: ${{ fromJson(needs.setup.outputs.matrix) }}
runs-on: ubuntu-latest
steps:
- run: npm test --workspace packages/${{ matrix.package }}
Limiting parallelism to control costs
strategy:
max-parallel: 3 # Run at most 3 jobs simultaneously
matrix:
package: [api, auth, payments, notifications, dashboard, reporting]
# Without max-parallel: all 6 run at once
# With max-parallel: 3: batches of 3
Referencing matrix values in job names
jobs:
test:
name: Test (Node ${{ matrix.node-version }}, ${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [18, 20]
# Job names appear as:
# Test (Node 18, ubuntu-latest)
# Test (Node 20, ubuntu-latest)
# Test (Node 18, windows-latest)
# Test (Node 20, windows-latest)
Required status checks with matrix
If you use matrix jobs as required checks for PRs, GitHub requires every matrix combination to pass. This is correct behaviour but can be a problem when you use dynamic matrices (the check names vary). Workaround:
jobs:
# Sentinel job that passes only when all matrix jobs pass
all-tests-passed:
needs: test
runs-on: ubuntu-latest
if: always()
steps:
- name: Check all tests passed
run: |
if [ "${{ needs.test.result }}" != "success" ]; then
echo "Some tests failed"
exit 1
fi
Set "all-tests-passed" as the required check. It gives you a single stable check name regardless of how many matrix combinations you have.