Skip to content
Codeloom
CI/CD

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.

·7 min read · By Codeloom
Intermediate 11 min read

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:

  1. Test the full matrix only on main and 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"]') }}
  1. Cache aggressively. Use actions/setup-node with cache: 'npm' so each matrix job does not re-download dependencies.
  2. Use max-parallel to 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.