GitHub Actions matrix strategy — parallel jobs done right
← Back
April 4, 2026DevOps6 min read

GitHub Actions matrix strategy — parallel jobs done right

Published April 4, 20266 min read

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

yaml
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

yaml
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

yaml
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

yaml
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:

yaml
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:

yaml
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

yaml
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

yaml
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:

yaml
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.

Share this
← All Posts6 min read