Matrix builds let you run your GitHub Actions jobs across multiple configurations simultaneously, drastically cutting down on CI/CD time.

Here’s a matrix build in action, testing a Node.js app against different Node versions and operating systems:

name: Node.js CI

on: [push]

jobs:
  build:

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

    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node-version: [12.x, 14.x, 16.x]

    steps:
    - uses: actions/checkout@v3

    - name: Use Node.js ${{ matrix.node-version }}

      uses: actions/setup-node@v3
      with:

        node-version: ${{ matrix.node-version }}

    - run: npm ci
    - run: npm test

This configuration tells GitHub Actions to create a separate job for every combination of os and node-version defined in the matrix. In this case, that’s 3 OS options * 3 Node versions = 9 distinct jobs. Each job gets its own runner, its own environment, and runs independently of the others. The runs-on directive dynamically pulls values from the matrix, ensuring each job spins up on the correct OS.

The core problem matrix builds solve is the combinatorial explosion of testing needs. You don’t just want to test your code on one OS with one dependency version; you want to ensure it works everywhere your users might be. Manually creating jobs for each permutation is tedious, error-prone, and scales poorly. A matrix build automates this, allowing you to define the dimensions of your test space and let GitHub Actions handle the job creation.

Internally, GitHub Actions processes the strategy.matrix configuration. It calculates all possible combinations. For each combination, it generates a unique job ID and configures a runner with the specified runs-on environment and any other matrix-defined variables (like node-version in our example). These jobs are then queued and executed in parallel, subject to your Actions runner concurrency limits.

You can also include other variables in your matrix. For instance, you might want to test different versions of a dependency:

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [14.x, 16.x]
        dependency-version: [v1, v2]
    steps:
      # ... setup node and install dependency based on matrix.dependency-version

This would create 4 jobs (2 Node versions * 2 dependency versions). The matrix object becomes available within your job steps, allowing you to dynamically configure your environment or commands.

When you have multiple dimensions in your matrix, you can exclude specific combinations that don’t make sense or are known to fail. For example, if a certain Node version doesn’t support a particular feature on macOS, you can exclude it:

jobs:
  build:

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

    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
        node-version: [14.x, 16.x]
      fail-fast: false # Prevent subsequent jobs from running if one fails
      exclude:
        - os: macos-latest
          node-version: 14.x

The fail-fast: false setting is crucial for debugging. By default, if one job in a matrix fails, GitHub Actions will cancel all other running jobs in that matrix. Setting fail-fast: false allows all jobs to complete, giving you a clearer picture of all the failures and enabling you to fix them systematically.

The include keyword lets you add extra configurations to your matrix without duplicating existing ones. This is useful for adding a specific, one-off test case or a configuration that doesn’t fit the main pattern.

jobs:
  build:

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

    strategy:
      matrix:
        os: [ubuntu-latest]
        node-version: [16.x]
        include:
          - os: windows-latest
            node-version: 16.x
          - os: ubuntu-latest
            node-version: 14.x
            testing-extra-feature: true

This matrix would run three jobs: one on windows-latest with Node 16.x, one on ubuntu-latest with Node 16.x, and a third on ubuntu-latest with Node 14.x, specifically for testing an extra feature.

A common pitfall is forgetting that each job in a matrix runs on a fresh, clean runner. Any artifacts or build outputs from one job are not automatically available to another. If you need to share data between matrix jobs, you’ll need to use GitHub Actions artifacts.

You can also include environment variables directly in your matrix, making them available to all steps within that specific job. This is handy for passing configuration values that change per matrix permutation.

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [14.x, 16.x]
        env:
          NODE_OPTIONS: [--max-old-space-size=4096] # Example: Increase memory for tests
    steps:

      - name: Use Node.js ${{ matrix.node-version }}

        uses: actions/setup-node@v3
        with:

          node-version: ${{ matrix.node-version }}

      - run: echo "Node options are: $NODE_OPTIONS"

This job would run twice, once with NODE_OPTIONS set to --max-old-space-size=4096 and once without it, allowing you to test performance under different memory constraints.

The next step is to explore how to control concurrency and manage large matrices effectively.

Want structured learning?

Take the full Github-actions course →