Skip to content
Codeloom
CI/CD

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.

·5 min read · By Codeloom
Intermediate 10 min read

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.