GitHub Actions Matrix Builds for Parallel Testing
Use GitHub Actions matrix strategies to test across multiple OS, language, and dependency versions in parallel. Covers dynamic matrices, fail-fast, and includes/excludes.
What you'll learn
- ✓How matrix strategies run jobs in parallel across multiple dimensions
- ✓Configuring include and exclude rules for specific combinations
- ✓Building dynamic matrices from scripts or API responses
- ✓Controlling failure behavior with fail-fast and max-parallel
- ✓Practical patterns for multi-platform library testing
Prerequisites
None — this post is self-contained.
When your project needs to work across multiple Node versions, operating systems, or database backends, testing each combination sequentially is slow and fragile. GitHub Actions matrix strategies let you define multiple dimensions and run every combination in parallel. A single workflow definition can spawn dozens of parallel jobs without duplicating any configuration.
Basic Matrix Strategy
A matrix strategy defines variables and their possible values. GitHub Actions creates one job for every combination:
name: CI
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 creates 9 jobs (3 OS x 3 Node versions), all running in parallel. Each job has access to matrix.os and matrix.node as context variables.
Include and Exclude
Not every combination is valid or necessary. Use exclude to remove specific combinations and include to add individual entries with extra variables:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
exclude:
# Node 18 on Windows has a known issue, skip it
- os: windows-latest
node: 18
include:
# Add a specific combination with extra settings
- os: ubuntu-latest
node: 22
coverage: true
experimental: false
The include entries add properties to matching combinations. If no existing combination matches, a new one is created. In this example, the ubuntu-latest + node 22 job gets matrix.coverage set to true.
Use the extra variables to conditionally run steps:
steps:
- run: npm test
- name: Upload coverage
if: matrix.coverage
run: npx codecov
Dynamic Matrices
Static matrices work well when the combinations are known ahead of time. For dynamic scenarios, like testing against all supported database versions fetched from an API, generate the matrix in a preceding job:
jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- id: set-matrix
run: |
# Generate matrix from a script, API, or file
echo 'matrix={"python-version":["3.10","3.11","3.12"],"django-version":["4.2","5.0","5.1"]}' >> $GITHUB_OUTPUT
test:
needs: setup
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJson(needs.setup.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install django==${{ matrix.django-version }}
- run: python -m pytest
A more practical example reads supported versions from a configuration file in your repository:
- id: set-matrix
run: |
# Read versions from a YAML config file
VERSIONS=$(python -c "
import json, yaml
with open('.ci/test-versions.yml') as f:
config = yaml.safe_load(f)
print(json.dumps(config))
")
echo "matrix=$VERSIONS" >> $GITHUB_OUTPUT
This way, updating the test matrix is a config file change rather than a workflow edit.
Fail-Fast and Max-Parallel
By default, fail-fast is true, meaning all in-progress jobs are cancelled when any matrix job fails. This saves compute minutes but can hide failures in other combinations:
strategy:
fail-fast: false # Let all combinations finish
max-parallel: 4 # Limit concurrent jobs
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
Set fail-fast: false when you need a complete compatibility report. Set max-parallel when you want to avoid overwhelming external services like test databases or API rate limits.
Practical Patterns
Multi-Platform Library
For a library that must work on all platforms with all supported language versions:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
go: ['1.21', '1.22', '1.23']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- run: go test ./...
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: golangci/golangci-lint-action@v6
Linting runs once since it does not depend on platform or version. Testing runs across all 9 combinations.
Database Compatibility
Test against multiple database versions using service containers:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
postgres: ['14', '15', '16']
services:
db:
image: postgres:${{ matrix.postgres }}
env:
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/test
Conditional Matrix with Reusable Workflows
Combine matrices with reusable workflows for complex pipelines:
# .github/workflows/test-and-deploy.yml
jobs:
test:
strategy:
matrix:
environment: [staging, production]
uses: ./.github/workflows/run-tests.yml
with:
target: ${{ matrix.environment }}
secrets: inherit
deploy:
needs: test
if: github.ref == 'refs/heads/main'
strategy:
matrix:
region: [us-east-1, eu-west-1, ap-southeast-1]
max-parallel: 1 # Deploy to regions sequentially
uses: ./.github/workflows/deploy.yml
with:
region: ${{ matrix.region }}
secrets: inherit
The max-parallel: 1 on the deploy job ensures regions are deployed one at a time, allowing you to catch issues before rolling out globally.
Debugging Matrix Jobs
When a specific matrix combination fails, reproduce it locally by checking the job name in the Actions UI. GitHub names matrix jobs using their variable values, like test (ubuntu-latest, 20).
Use continue-on-error with a matrix variable to mark experimental combinations as non-blocking:
strategy:
matrix:
node: [18, 20, 22]
include:
- node: 23
experimental: true
continue-on-error: ${{ matrix.experimental || false }}
Node 23 failures will not fail the overall workflow, but you still see the results.
Key Takeaways
Matrix builds turn one job definition into a parallel test grid across any combination of platforms, runtimes, and dependencies. Use exclude to skip invalid combinations, include to add special cases with extra variables, and dynamic matrices when the combinations are not known at author time. Set fail-fast: false for complete compatibility reports and max-parallel to control resource usage. The goal is comprehensive coverage without duplicating workflow configuration.
Related articles
- CI/CD GitHub Actions vs Jenkins: CI/CD Compared
Compare GitHub Actions and Jenkins for CI/CD pipelines. Covers setup, configuration, plugins, pricing, and when to choose each tool for your team.
- CI/CD CI/CD Artifact Management: Build, Store, and Promote
Learn how to manage build artifacts in CI/CD pipelines including versioning, storage, promotion strategies, and cleanup policies.
- CI/CD Automated Testing in CI/CD: Unit, Integration, and E2E
Structure automated tests in your CI/CD pipeline — unit, integration, and end-to-end testing strategies, parallel execution, and failure handling.
- CI/CD Optimize Docker Builds in CI/CD Pipelines
Speed up Docker builds in CI/CD — multi-stage builds, layer caching, BuildKit, registry caching, and slim base images for faster deployments.