Switch Language
Toggle Theme

GitHub Actions Matrix Builds: A Practical Guide to Multi-Platform Multi-Version Parallel Testing

Last year, an open-source project reached out to me, saying their CI configuration file had ballooned to over 800 lines. I opened it and found it packed with repetitive job definitions—Node 16 on Ubuntu, Node 16 on Windows, Node 16 on macOS… then the same for Node 18, and again for Node 20. Need to change a test command? That’s 12 places to update. Adding a new version? Copy-paste for another ten minutes.

That’s when I realized many people are still manually maintaining multi-version multi-platform tests.

GitHub Actions’ Matrix feature essentially automates this expansion. You define a few operating systems, a few runtime versions, and it automatically runs all combinations for you. Sounds simple, but there are plenty of pitfalls when you actually use it—exploding combination counts leading to billing explosions, one task failure taking down the entire pipeline, not knowing how to exclude specific combinations… I’ve encountered all of these issues.

This article starts with basic Matrix syntax and walks you through advanced techniques: exclude/include for precise control, fail-fast strategy selection, max-parallel concurrency limits, and dynamic Matrix generation. Finally, I’ll give you 5 production-ready workflow templates you can copy directly into your projects, covering scenarios from personal projects to enterprise applications.

2. Matrix Core Concepts: Expanding Multiple Tasks with One Click

Matrix logic is straightforward: you define several dimensions, and GitHub Actions automatically performs a Cartesian product expansion.

For example, your project needs testing on Ubuntu, Windows, and macOS, while also maintaining compatibility with Node.js 18, 20, and 22. Traditionally, you’d hand-write 9 jobs, each repeating environment definitions, installation steps, and test commands. With Matrix, it looks like this:

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    node: [18, 20, 22]

runs-on: ${{ matrix.os }}

steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: ${{ matrix.node }}
  - run: npm test

These 10 lines of configuration expand into 3 x 3 = 9 parallel tasks. Each task receives different matrix.os and matrix.node values, running through all combinations.

The 800-line configuration file I mentioned earlier was reduced to about 120 lines after refactoring with Matrix—a 60%+ reduction in code. Maintenance costs dropped too—adding a new version is just adding one number to the array, no more copying and pasting job definitions.

What Matrix can do for you:

  • Generate multi-platform multi-version test combinations with one click
  • Automatically expand all configurations, avoiding repetitive hand-written code
  • Exclude problematic combinations using exclude
  • Add special configuration test cases using include
  • Control concurrency to balance speed and cost

What it can’t solve:

  • Poorly written tests—Matrix can’t fix that
  • Bill explosions from too many combinations—that’s on you to control dimension counts
  • Slow dependency installation—needs caching strategies

Honestly, Matrix itself isn’t hard to understand. The challenge is applying it to solve real engineering problems. Let’s break it down step by step, starting with basic syntax.

3. Basic Syntax: The os x version Combination Principle

Matrix combination rules are essentially Cartesian products from mathematics. Each dimension you define gets fully permuted with other dimensions.

One dimension, N values -> N tasks

Two dimensions, M x N values -> M x N tasks

Three dimensions, A x B x C values -> A x B x C tasks

Here’s a concrete example. Suppose you have a Python project that needs testing on Linux and Windows with Python 3.9, 3.10, 3.11, and 3.12, while also testing both PostgreSQL and MySQL databases:

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        python-version: ['3.9', '3.10', '3.11', '3.12']
        database: [postgresql, mysql]
    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - name: Setup ${{ matrix.database }}
        run: |
          # Start the corresponding database service
          if [ "${{ matrix.database }}" = "postgresql" ]; then
            docker run -d -p 5432:5432 postgres
          else
            docker run -d -p 3306:3306 mysql
          fi
        shell: bash
      - run: pip install -r requirements.txt
      - run: pytest

This configuration generates 2 x 4 x 2 = 16 tasks. Each task runs in an independent environment without interfering with others.

Ways to access Matrix variables:

  • ${{ matrix.os }} — Get the current task’s operating system
  • ${{ matrix.python-version }} — Get the current task’s Python version
  • ${{ matrix.database }} — Get the current task’s database type

These variables can be used in runs-on, steps, env, and other places to dynamically adjust each task’s behavior.

A common pitfall: Many people think Matrix automatically handles dependency installation. Actually, each task is an independent environment, so dependency installation gets repeated. This creates a problem—if your dependency installation takes 2 minutes, 16 tasks mean 32 minutes of waiting (assuming serial execution).

There are two solutions:

  1. Use caching — Cache pip or npm dependency directories to skip repeated downloads
  2. Reduce combination count — Exclude unnecessary test combinations via exclude

I covered caching strategies in detail in my previous article on GitHub Actions caching. Let’s focus on how to use exclude/include for precise control over test combinations.

4. exclude/include: Precise Control Over Test Combinations

Matrix defaults to fully permuting all dimensions, but in real projects, you often encounter situations where “certain combinations don’t need testing” or “certain combinations need special handling.”

4.1 exclude: Removing Invalid Combinations

I encountered this issue while maintaining a Python project: Windows + Python 3.9 combinations always failed because a dependency library had compatibility issues with Python 3.9 on Windows. But this project mainly targets Linux server deployments—Windows support is secondary—not worth fixing this specific bug.

That’s where exclude comes in handy:

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    python-version: ['3.9', '3.10', '3.11', '3.12']
    exclude:
      - os: windows-latest
        python-version: '3.9'
      - os: macos-latest
        python-version: '3.9'

This configuration excludes Python 3.9 tests on Windows and macOS. Originally 3 x 4 = 12 tasks, after excluding 2 becomes 10 tasks.

Typical exclude use cases:

  1. Known compatibility issues — A version doesn’t work on a specific system
  2. Resource constraints — Limited self-hosted runners, need to reduce combination counts
  3. Edge cases — Certain combinations users rarely use, not worth CI time

4.2 include: Adding Special Configurations

include does the opposite—it helps you add extra test combinations, or supplement additional variables for specific combinations.

For example, you want to enable coverage reports only in Python 3.12 tests, but not for other versions:

strategy:
  matrix:
    python-version: ['3.10', '3.11', '3.12']
    include:
      - python-version: '3.12'
        coverage: true

steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-python@v5
    with:
      python-version: ${{ matrix.python-version }}
  - run: pip install -r requirements.txt
  - name: Run tests
    run: |
      if [ "${{ matrix.coverage }}" = "true" ]; then
        pytest --cov=src --cov-report=xml
      else
        pytest
      fi
    shell: bash

Here include does two things:

  1. Add a new combination — Python 3.12 test
  2. Supplement extra variables for this combinationcoverage: true

Typical include use cases:

  1. Experimental version testing — Like Python 3.13 preview, only testing on one system
  2. Special configurations — Certain combinations need extra environment variables or parameters
  3. Edge case coverage — Low-frequency combinations, added individually rather than through full permutation

4.3 exclude and include Can Be Used Together

In real projects, you often need both exclude and include. For example: exclude all Python 3.9 combinations, but separately add one Ubuntu + Python 3.9 minimal test:

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest]
    python-version: ['3.9', '3.10', '3.11', '3.12']
    exclude:
      - python-version: '3.9'
    include:
      - os: ubuntu-latest
        python-version: '3.9'
        minimal: true

This configuration executes in order: generate all combinations -> apply exclude -> apply include. Final result: 4 versions on Ubuntu, 3 versions on Windows (excluding 3.9).

5. fail-fast Strategy: Quick Failure vs Complete Debugging

Matrix tasks have a default behavior: when one task fails, other running tasks get cancelled. This behavior is called fail-fast, enabled by default.

strategy:
  fail-fast: true  # Default value, can be omitted
  matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]

5.1 When to Use fail-fast: true (Default)

PR testing scenarios — A developer submits a PR, you want to quickly know if tests pass. One task fails, others likely will too (same code issues), no point wasting more time.

Cost-sensitive scenarios — GitHub Actions free tier is limited, self-hosted runner resources are limited too. Fast failure saves money.

My personal habit: use fail-fast: true for PR tests, fail-fast: false for main branch full tests.

5.2 When to Use fail-fast: false

Debugging phase — Your Matrix tasks fail frequently, but you want to know which specific combinations fail and what the different failure reasons are. With fail-fast: true, you only see the first failed task—others get cancelled.

Compatibility testing — You’re doing multi-version multi-platform compatibility testing, want to know each combination’s test results. Even if one version has issues, doesn’t affect getting information about other versions.

Complete report scenarios — You need to generate a complete test report after CI finishes, including pass/fail status for all combinations.

strategy:
  fail-fast: false  # Let all tasks complete
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    node: [18, 20, 22]

5.3 A Real Case

Last year I helped troubleshoot CI issues for a project. Their tests always failed on Ubuntu + Node 18, but other combinations passed. Because fail-fast was enabled by default, they could only see Ubuntu + Node 18 failing, then other tasks got cancelled. Later they wanted to know if Windows + Node 18 had issues too, so they changed to fail-fast: false, discovering Windows was fine—only Ubuntu had the problem. Eventually identified it as a file path case sensitivity compatibility issue.

So my advice: Use fail-fast: false during development debugging to see all problems clearly; use fail-fast: true during stable operation to save money and time.

6. max-parallel: Concurrency Control and Cost Optimization

Matrix tasks execute in parallel by default, with GitHub starting as many simultaneous tasks as possible. For public repositories, GitHub-hosted runner concurrency limit is 20; for private repositories, free account limit is 2.

But sometimes you need manual control over concurrency—that’s where max-parallel comes in.

6.1 When You Need to Limit Concurrency

Limited self-hosted runner resources — Your runner server only has 4 cores 8GB RAM, running 8 tasks simultaneously will overwhelm the machine.

External service rate limiting — Your tests need to call third-party APIs, they have QPS limits, too much concurrency gets you blocked.

Database connection pool limits — Your tests need database connections, connection pool only has 10 connections, too many tasks exhaust them.

strategy:
  max-parallel: 4  # Maximum 4 tasks running simultaneously
  matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]

This configuration generates 6 tasks, but at most 4 run simultaneously. One finishes, next one starts.

6.2 A Cost Calculation Example

Suppose you have a project where each CI run needs testing 3 systems x 4 Node versions = 12 tasks. Each task averages 10 minutes.

No concurrency limit (assuming enough runners):

  • 12 tasks run simultaneously
  • Total time about 10 minutes
  • Total compute time = 12 x 10 = 120 minutes

Limit max-parallel: 4:

  • 12 tasks run in 3 batches
  • Total time about 30 minutes
  • Total compute time = 12 x 10 = 120 minutes (unchanged)

See? max-parallel doesn’t reduce total compute time, only extends total duration. So why use it?

Because of peak concurrency costs and resource limits.

GitHub Actions bills by “minutes,” but if you use self-hosted runners, or your cloud provider bills by peak usage, concurrency control becomes important. For example, 12 tasks running simultaneously means your database needs 12 connections; running in batches, only 4 connections needed.

My practical experience:

  • Public repos, GitHub-hosted runners: Don’t worry about max-parallel, let it schedule itself
  • Private repos, free tier: Limit max-parallel: 2, run slowly, don’t exceed quota
  • Self-hosted runners: Limit max-parallel based on server config, 4 cores suggest 2-4 concurrent

7. Dynamic Matrix: fromJSON Advanced Techniques

The Matrix examples earlier are all static configurations—you hardcode versions to test in YAML. But in some scenarios, you need to dynamically generate test combinations based on code changes.

For example, you have a monorepo with multiple services, each with its own test configuration. You want to: only test services affected by this commit, rather than running all services.

7.1 Two-step Workflow for Dynamic Matrix

GitHub Actions doesn’t directly support “dynamic matrix” syntax, but you can use one job to generate matrix configuration, then pass it to another job. The key is the fromJSON() function.

jobs:
  # Step 1: Detect changed services, generate matrix config
  detect:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # Need to get previous commit

      - name: Detect changed services
        id: set-matrix
        run: |
          # Get files changed in this commit
          CHANGED_FILES=$(git diff --name-only HEAD^ HEAD)

          # Determine which services were modified
          SERVICES="[]"
          if echo "$CHANGED_FILES" | grep -q "services/auth/"; then
            SERVICES=$(echo $SERVICES | jq '. + ["auth"]')
          fi
          if echo "$CHANGED_FILES" | grep -q "services/api/"; then
            SERVICES=$(echo $SERVICES | jq '. + ["api"]')
          fi
          if echo "$CHANGED_FILES" | grep -q "services/web/"; then
            SERVICES=$(echo $SERVICES | jq '. + ["web"]')
          fi

          # If no services modified, default test all services
          if [ "$SERVICES" = "[]" ]; then
            SERVICES='["auth", "api", "web"]'
          fi

          echo "matrix={\"service\":$(echo $SERVICES)}" >> $GITHUB_OUTPUT

  # Step 2: Use dynamically generated matrix
  test:
    needs: detect
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJSON(needs.detect.outputs.matrix) }}

    steps:
      - uses: actions/checkout@v4
      - name: Test ${{ matrix.service }}
        run: |
          cd services/${{ matrix.service }}
          npm install
          npm test

How this workflow works:

  1. detect job checks which directories were modified in this commit
  2. Based on modified directories, dynamically generates a JSON format matrix configuration
  3. test job uses fromJSON() to parse this configuration and generate corresponding tasks

7.2 Typical Dynamic Matrix Use Cases

Monorepo scenarios — Only test changed services, saving CI time

On-demand deployment — Detect Dockerfile changes, only build and deploy updated images

Matrix test optimization — Decide test combinations based on file types (e.g., only run full multi-version tests when package.json changes)

Pitfalls I’ve encountered:

  1. fromJSON() only works on strategy.matrix values, not elsewhere
  2. Generated JSON must be valid matrix format, like {"service": ["auth", "api"]}
  3. If generated matrix is empty, workflow errors directly—remember to add default value handling

8. Template Library: 5 Production-Ready Workflow Examples

Here are 5 workflow templates you can copy directly, covering scenarios from personal projects to enterprise applications.

8.1 Template 1: Node.js Multi-Version Testing (Basic)

Use case: Node.js libraries or applications needing compatibility with multiple Node versions

name: Node.js CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node-version: [18, 20, 22, 23]

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - run: npm ci
      - run: npm run build --if-present
      - run: npm test

      - name: Upload coverage
        if: matrix.node-version == 22
        uses: codecov/codecov-action@v4

Key points:

  • Use npm ci instead of npm install to ensure locked dependency versions
  • Only upload coverage reports on Node 22, avoiding duplicate uploads
  • cache: 'npm' enables npm caching to speed up dependency installation

8.2 Template 2: Python Multi-Platform Multi-Version Testing (Intermediate)

Use case: Python projects needing cross-platform cross-version testing

name: Python CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        python-version: ['3.10', '3.11', '3.12']
        exclude:
          - os: windows-latest
            python-version: '3.10'  # Known compatibility issue

    steps:
      - uses: actions/checkout@v4

      - name: Setup Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run tests
        run: pytest -v

      - name: Lint check
        run: |
          pip install ruff
          ruff check .

Key points:

  • Use exclude to remove known problematic combinations
  • cache: 'pip' speeds up pip dependency installation
  • Integrate code checking tool ruff

8.3 Template 3: exclude/include Precise Control (Advanced)

Use case: Need fine-grained control over test combinations, excluding specific combinations, adding special tests

name: Advanced Matrix

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
        python-version: ['3.10', '3.11', '3.12']
        exclude:
          # Exclude Windows + Python 3.10 (known issue)
          - os: windows-latest
            python-version: '3.10'
        include:
          # Add experimental test: Ubuntu + Python 3.13 preview
          - os: ubuntu-latest
            python-version: '3.13-dev'
            experimental: true
          # Add coverage report for Python 3.12
          - python-version: '3.12'
            coverage: true

    continue-on-error: ${{ matrix.experimental == true }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'

      - run: pip install -r requirements.txt

      - name: Run tests
        run: |
          if [ "${{ matrix.coverage }}" = "true" ]; then
            pytest --cov=src --cov-report=xml
          else
            pytest
          fi
        shell: bash

Key points:

  • continue-on-error lets experimental test failures not affect overall status
  • include can simultaneously add new combinations and supplement variables
  • Use shell: bash to ensure Windows and Linux commands are consistent

8.4 Template 4: Dynamic Matrix + Caching (Advanced)

Use case: Monorepo, dynamically generating test combinations based on file changes

name: Dynamic Matrix CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - name: Detect changed packages
        id: set-matrix
        run: |
          CHANGED_FILES=$(git diff --name-only HEAD^ HEAD)

          PACKAGES="[]"
          for dir in packages/*/; do
            pkg=$(basename $dir)
            if echo "$CHANGED_FILES" | grep -q "^packages/$pkg/"; then
              PACKAGES=$(echo $PACKAGES | jq ". + [\"$pkg\"]")
            fi
          done

          # Test all packages when no changes
          if [ "$PACKAGES" = "[]" ]; then
            PACKAGES='["core", "utils", "cli"]'
          fi

          echo "matrix={\"package\":$(echo $PACKAGES)}" >> $GITHUB_OUTPUT

  test:
    needs: detect
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJSON(needs.detect.outputs.matrix) }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build --if-present

      - name: Test ${{ matrix.package }}
        run: |
          cd packages/${{ matrix.package }}
          npm test

Key points:

  • fetch-depth: 2 gets previous commit for comparison
  • Use jq command to manipulate JSON arrays
  • Provide default values when no changes to avoid empty matrix errors

8.5 Template 5: Self-hosted Runner + max-parallel (Enterprise)

Use case: Self-hosted runners, need strict control over concurrency and resources

name: Enterprise CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: [self-hosted, linux, x64]
    strategy:
      fail-fast: true
      max-parallel: 4
      matrix:
        java-version: [11, 17, 21]
        database: [postgresql, mysql]

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      mysql:
        image: mysql:8
        env:
          MYSQL_ROOT_PASSWORD: root
        ports:
          - 3306:3306
        options: >-
          --health-cmd "mysqladmin ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Setup Java ${{ matrix.java-version }}
        uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java-version }}
          distribution: 'temurin'
          cache: 'maven'

      - name: Run tests with ${{ matrix.database }}
        env:
          DB_TYPE: ${{ matrix.database }}
          DB_HOST: localhost
          DB_PORT: ${{ matrix.database == 'postgresql' && 5432 || 3306 }}
        run: mvn test -Dspring.profiles.active=${{ matrix.database }}

      - name: Archive test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results-${{ matrix.java-version }}-${{ matrix.database }}
          path: target/surefire-reports

Key points:

  • runs-on: [self-hosted, linux, x64] specifies self-hosted runner labels
  • max-parallel: 4 limits concurrency, protecting runner server
  • Use services to start test database containers
  • if: always() ensures test results are uploaded even if tests fail

9. Common Pitfalls and Best Practices

Using Matrix for so long, I’ve stepped in quite a few pits. Here are the most common ones.

9.1 Pitfall 1: Combination Count Explosion

The most extreme configuration I’ve seen: 4 operating systems x 5 runtime versions x 3 databases x 2 caching schemes = 120 tasks. Each CI run took 45 minutes, bills exploded.

Solutions:

  • Only run full Matrix on main branch, PRs only run key combinations
  • Use exclude to remove edge cases
  • Evaluate necessity of each dimension—do you really need to test 4 operating systems?
# PR only tests key combinations
on:
  pull_request:
    branches: [main]

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest]  # PR only tests Ubuntu
        node: [20]            # PR only tests Node 20

9.2 Pitfall 2: fail-fast Hinders Debugging

Default fail-fast: true is annoying during debugging—one task fails, others get cancelled, you can’t see the complete failure report.

Solution: Manually change to fail-fast: false during debugging, change back after.

Or control with environment variables:

strategy:
  fail-fast: ${{ github.event_name == 'pull_request' }}

9.3 Pitfall 3: Missing Caching

Matrix repeats the same job multiple times. If you reinstall dependencies each time, time costs are high. I once tested 12 combinations, each installation took 2 minutes—that’s 24 minutes just for installation.

Solution: Use GitHub Actions caching, or dedicated caching actions.

- uses: actions/setup-node@v4
  with:
    node-version: ${{ matrix.node-version }}
    cache: 'npm'  # Key: enable npm caching

9.4 Best Practices Summary

Practice 1: Small Matrix for PRs + Full Matrix for main

jobs:
  test:
    strategy:
      matrix:
        # PR only tests key combinations
        ${{ github.event_name == 'pull_request' && fromJSON('{"os":["ubuntu-latest"],"node":[20]}') || fromJSON('{"os":["ubuntu-latest","windows-latest","macos-latest"],"node":[18,20,22]}') }}

Practice 2: exclude Removes Known Problem Combinations

When encountering compatibility issues with specific combinations, first use exclude to skip, make a TODO, fix later.

Practice 3: Combine Caching to Reduce Installation Time

Dependency installation for each job is one of the most time-consuming steps. Good caching can reduce time from minutes to seconds.

Practice 4: Add Meaningful Names to Combinations

Default task names are formatted like test (ubuntu-latest, 20). You can customize with name:

jobs:
  test:
    name: Test (${{ matrix.os }}, Node ${{ matrix.node }})
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node: [18, 20, 22]

This makes each task easier to identify in the GitHub Actions interface.

10. Conclusion

GitHub Actions Matrix is a powerful tool for handling multi-platform multi-version testing. The core is just a few things: define dimensions, control combinations, manage concurrency, generate dynamically.

I’ve seen too many projects still manually maintaining repetitive CI configurations—changing one command means updating dozens of places. Matrix can compress hundreds of lines of configuration into tens, maintenance costs drop immediately.

Key takeaways:

  1. Basic syntax: matrix.os and matrix.node for Cartesian product expansion
  2. Precise control: exclude removes invalid combinations, include adds special configurations
  3. Strategy selection: Choose fail-fast based on scenario—false for debugging, true for production
  4. Concurrency limits: max-parallel protects self-hosted runners, controls costs
  5. Dynamic generation: fromJSON() enables on-demand testing, saves CI resources

The 5 templates in this article can be copied directly into your projects, covering everything from simple Node.js multi-version testing to enterprise self-hosted runner configurations.

If you’re just starting with Matrix, I suggest starting with Template 1, get it working, then add exclude and include, finally try dynamic generation. Take it step by step—don’t dive into complexity right away.

Questions? Leave a comment. You can also check the GitHub Actions official documentation for more details. If you have Matrix usage insights, feel free to share.

Configure GitHub Actions Matrix multi-platform multi-version testing

Configure Matrix strategy from scratch for cross-platform cross-version automated testing

⏱️ Estimated time: 30 min

  1. 1

    Step1: Define Matrix dimensions

    Add strategy.matrix configuration to workflow job:

    ```yaml
    strategy:
    matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]
    ```

    This generates 2 x 3 = 6 parallel tasks.
  2. 2

    Step2: Use Matrix variables

    Reference matrix variables in runs-on and steps:

    ```yaml
    runs-on: ${{ matrix.os }}
    steps:
    - uses: actions/setup-node@v4
    with:
    node-version: ${{ matrix.node }}
    ```

    Each task automatically gets corresponding os and node values.
  3. 3

    Step3: Exclude specific combinations (optional)

    Use exclude to remove known problematic combinations:

    ```yaml
    strategy:
    matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]
    exclude:
    - os: windows-latest
    node: 18
    ```

    Excludes Windows + Node 18 combination, generating 5 tasks.
  4. 4

    Step4: Configure failure strategy

    Choose fail-fast strategy based on scenario:

    - PR testing: fail-fast: true (fail fast, save money)
    - Debugging phase: fail-fast: false (see all failures)
    - main branch: fail-fast: false (complete report)

    ```yaml
    strategy:
    fail-fast: false
    matrix:
    # ...
    ```
  5. 5

    Step5: Limit concurrency (optional)

    Limit concurrency when self-hosted runners or resources are limited:

    ```yaml
    strategy:
    max-parallel: 4
    matrix:
    # ...
    ```

    Maximum 4 tasks running simultaneously, avoiding runner overload.

FAQ

Is there a limit on Matrix combination count?
GitHub has soft limits on Matrix task counts. Public repositories max 256 tasks, private repositories vary by plan. But practically, keep combinations under 20 to avoid excessive CI time and bill explosions. 4 operating systems x 5 versions x 3 databases = 60 tasks is already a lot.
What is the default value of fail-fast?
fail-fast defaults to true. When one task fails, other running tasks get cancelled. During debugging, set to false to see all failure reasons; in production, use the default for fast failure to save costs.
What is the execution order of exclude and include?
Execution order: generate all combinations -> apply exclude -> apply include. So you can first exclude all Python 3.9 combinations, then use include to add Ubuntu + Python 3.9 minimal testing separately.
Where can fromJSON() be used in dynamic matrix?
fromJSON() only works on strategy.matrix values, not other YAML fields. Generated JSON must be valid matrix format, like {"os": ["ubuntu", "windows"]}. If generated matrix is empty, workflow errors—remember to add default value handling.
Does max-parallel reduce total compute time?
No. max-parallel only controls simultaneously running task count, not reducing total compute time. 12 tasks x 10 minutes = 120 minutes compute time, regardless of concurrency. But limiting concurrency can: 1) reduce peak resource usage; 2) avoid database connection pool exhaustion; 3) control self-hosted runner load.
Can Matrix tasks share caches?
Yes. GitHub Actions caching is repository-level, all jobs can access it. Enable cache parameter in setup-node or setup-python to automatically cache dependency directories. Recommend enabling caching in all tasks—first task creates cache, subsequent tasks use it directly.
How to add custom names to Matrix tasks?
Use job's name attribute with matrix variables:

• name: Test (${{ matrix.os }}, Node ${{ matrix.node }})

This displays friendly names in GitHub Actions interface like "Test (ubuntu-latest, Node 20)", making each task easy to identify.

15 min read · Published on: Apr 28, 2026 · Modified on: Apr 29, 2026

Comments

Sign in with GitHub to leave a comment