GitHub Actions Matrix Builds: Parallel Testing at Scale
Master GitHub Actions matrix strategies with fail-fast control, dynamic matrices from scripts, include/exclude rules, and real-world multi-platform testing patterns.
What you'll learn
- ✓How matrix strategies generate parallel job combinations
- ✓Controlling failure behavior with fail-fast and max-parallel
- ✓Building dynamic matrices from scripts or API responses
- ✓Using include and exclude to fine-tune combinations
- ✓Real patterns for cross-platform library and service testing
Prerequisites
- •Basic GitHub Actions workflow syntax
- •Understanding of YAML
Testing your code across multiple Node versions, operating systems, and database backends one at a time is painfully slow. GitHub Actions matrix strategies let you declare dimensions and automatically spawn a job for every combination. A three-by-three matrix gives you nine parallel jobs from a single workflow definition.
How Matrix Strategies Work
A matrix is a set of variables, each with a list of values. GitHub Actions creates one job per combination:
name: Cross-Platform Tests
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
This generates 9 jobs (3 operating systems times 3 Node versions), all running in parallel. Each job has access to matrix.os and matrix.node as context variables.
Controlling Failure Behavior
fail-fast
By default, fail-fast is true. If any matrix job fails, GitHub cancels all other running jobs in the matrix. This saves runner minutes but hides whether failures are isolated or widespread:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
Set fail-fast: false when you need to see the full picture, for example when debugging platform-specific issues.
max-parallel
Limit concurrency to avoid overwhelming external services or hitting rate limits:
strategy:
max-parallel: 4
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
With 9 combinations but max-parallel: 4, GitHub runs 4 jobs at a time and queues the rest.
Include and Exclude Rules
Excluding Specific Combinations
Some combinations are invalid or unnecessary. Use exclude to remove them:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
exclude:
- os: windows-latest
node: 18
- os: macos-latest
node: 18
This removes two combinations, running 7 jobs instead of 9. Useful when older Node versions are not supported on certain platforms.
Adding Specific Combinations with Include
include adds extra dimension values to specific combinations or creates entirely new combinations:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [20, 22]
include:
- os: ubuntu-latest
node: 22
coverage: true
- os: ubuntu-latest
node: 23
experimental: true
The first include adds a coverage variable to the existing ubuntu-latest + node 22 combination. The second creates an entirely new combination with Node 23 that does not exist in the base matrix.
Use the extra variables in your steps:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
- if: matrix.coverage
run: npm run test:coverage
- if: matrix.experimental
continue-on-error: true
run: npm test
Dynamic Matrices
Static matrices are defined in YAML. Dynamic matrices are generated at runtime by a preceding job, enabling patterns like “test only changed packages” or “test against versions fetched from an API.”
Matrix from a Script
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: set-matrix
run: |
# Generate matrix from changed directories
CHANGED=$(git diff --name-only HEAD~1 HEAD | grep '^packages/' | cut -d'/' -f2 | sort -u | jq -R . | jq -sc .)
if [ "$CHANGED" = "[]" ]; then
CHANGED='["core"]'
fi
echo "matrix={\"package\":$CHANGED}" >> "$GITHUB_OUTPUT"
test:
needs: prepare
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- run: cd packages/${{ matrix.package }} && npm ci && npm test
The prepare job detects which packages changed and generates a JSON matrix. The test job consumes it with fromJson().
Matrix from an API Response
Fetch supported versions from an external source:
prepare:
runs-on: ubuntu-latest
outputs:
versions: ${{ steps.fetch.outputs.versions }}
steps:
- id: fetch
run: |
# Fetch active LTS Node versions from the unofficial API
VERSIONS=$(curl -s https://nodejs.org/dist/index.json | \
jq '[.[] | select(.lts != false) | .version | split(".")[0] | ltrimstr("v")] | unique | sort | .[-3:]')
echo "versions={\"node\":$VERSIONS}" >> "$GITHUB_OUTPUT"
test:
needs: prepare
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJson(needs.prepare.outputs.versions) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci && npm test
This ensures your matrix always reflects the currently active LTS versions without manual YAML updates.
Multi-Dimensional Matrices for Services
For backend services that need to test against different databases or message brokers, add service containers to the matrix:
jobs:
integration-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [20, 22]
database:
- engine: postgres
version: '15'
port: 5432
- engine: postgres
version: '16'
port: 5432
- engine: mysql
version: '8.0'
port: 3306
services:
db:
image: ${{ matrix.database.engine }}:${{ matrix.database.version }}
ports:
- ${{ matrix.database.port }}:${{ matrix.database.port }}
env:
POSTGRES_PASSWORD: testpass
MYSQL_ROOT_PASSWORD: testpass
options: >-
--health-cmd="pg_isready || mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm run test:integration
env:
DB_ENGINE: ${{ matrix.database.engine }}
DB_PORT: ${{ matrix.database.port }}
DB_PASSWORD: testpass
Matrix values can be objects, not just strings or numbers. This lets you group related configuration together.
Reusable Matrix Patterns
Continue-on-Error for Experimental Builds
Mark specific combinations as experimental so they do not block the pipeline:
strategy:
matrix:
node: [20, 22]
include:
- node: 23
experimental: true
continue-on-error: ${{ matrix.experimental || false }}
Conditional Steps by Matrix Value
Run certain steps only for specific matrix values:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
- if: matrix.node == 22 && matrix.os == 'ubuntu-latest'
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
Upload coverage from only one combination to avoid duplicate reports.
Matrix with Job Names
GitHub Actions auto-generates job names from matrix values. Customize them for readability:
test:
name: "Test Node ${{ matrix.node }} on ${{ matrix.os }}"
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [20, 22]
This produces job names like “Test Node 22 on ubuntu-latest” instead of “test (ubuntu-latest, 22)”.
Performance Considerations
Matrix builds consume runner minutes multiplicatively. A 3x3 matrix that takes 5 minutes per job uses 45 runner-minutes per run. Strategies to keep costs manageable:
- Test the full matrix only on
mainand nightly. On pull requests, test a reduced set:
strategy:
matrix:
node: ${{ github.event_name == 'pull_request' && fromJson('[22]') || fromJson('[18, 20, 22]') }}
os: ${{ github.event_name == 'pull_request' && fromJson('["ubuntu-latest"]') || fromJson('["ubuntu-latest", "macos-latest", "windows-latest"]') }}
- Cache aggressively. Use
actions/setup-nodewithcache: 'npm'so each matrix job does not re-download dependencies. - Use
max-parallelto stay within your plan’s concurrency limits.
Summary
Matrix strategies are the most efficient way to test across multiple dimensions in GitHub Actions. Start with a simple two-dimensional matrix, add include and exclude for edge cases, and graduate to dynamic matrices when your testing needs change frequently. Control costs with fail-fast, max-parallel, and conditional full-matrix runs. The result is comprehensive test coverage without maintaining dozens of nearly identical workflow files.
Related articles
- CI/CD CI/CD Artifact Management: Caching, Storage & Build Outputs
Master CI/CD artifact management with dependency caching, build artifact storage, retention policies, and cross-job artifact sharing patterns.
- CI/CD Building Custom GitHub Actions: JavaScript, Docker & Composite
Create your own GitHub Actions from scratch using JavaScript, Docker containers, and composite steps. Includes publishing to the marketplace.
- CI/CD GitHub Actions Reusable Workflows: Inputs, Secrets & Composition
Build maintainable CI/CD by creating reusable workflows with typed inputs, secret inheritance, and output chaining across repositories.
- CI/CD GitLab CI vs GitHub Actions: Feature Comparison & Migration Guide
A detailed comparison of GitLab CI/CD and GitHub Actions covering syntax, features, runners, and a practical migration guide with side-by-side examples.