Guides/GitHub ActionsGitHub Actions/Matrix Builds and Parallel Jobs in GitHub Actions

Matrix Builds and Parallel Jobs in GitHub Actions

How strategy.matrix expands one job into many across versions and OSes, plus include, exclude, fail-fast, max-parallel, and using needs and outputs to control parallelism.


The moment you need to test your code on more than one thing - three Node versions, two operating systems, a couple of database engines - the naive answer is to copy the job and edit one line in each copy. That works until it doesn't: six near-identical jobs drift out of sync, a fix lands in five of them, and nobody can tell at a glance what is actually being covered. A matrix solves this by expressing the variation as data. You describe the axes you want to vary, and GitHub expands them into one job per combination for you. This guide covers how matrices expand, how to add and remove specific combinations, how to control failure and parallelism, and how parallelism works more broadly through needs and job outputs. It pairs with the CI pipelines guide, which covers the workflow basics this builds on.

Why matrices exist

A CI job is a template: check out the code, install dependencies, run the tests. The only thing that changes when you want to test across versions is a value or two - which runtime, which OS. Duplicating the whole job to change one value is exactly the kind of repetition that rots. You end up maintaining N copies of the same logic, and the copies stop being identical the first time someone edits one in a hurry.

A matrix keeps a single job definition and multiplies it over the values you want to vary. Define an axis with three versions, and you get three jobs from one block of YAML. Add a second axis with two operating systems, and GitHub produces all six combinations automatically. There is one place to edit the steps, and the coverage is described declaratively as the set of things you care about. That is the whole pitch: say what varies, once, and let GitHub generate the runs.

A single-dimension matrix

Start with the simplest useful case: run the same tests against several versions of a language runtime. You define strategy.matrix with one key, whose value is a list. GitHub creates one job per list entry.

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        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 one job definition expands into three jobs that run in parallel: one on Node 18, one on 20, one on 22. Each gets its own runner and its own copy of the steps. The only difference between them is the value of matrix.node, which you read anywhere in the steps as ${{ matrix.node }}. GitHub names the jobs after their matrix values - test (18), test (20), test (22) - so the checks list tells you exactly which combination failed.

Multiple dimensions and how they expand

Add a second key and the matrix becomes multi-dimensional. GitHub takes the Cartesian product of the axes - every value of one crossed with every value of the other.

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-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

Two operating systems crossed with three Node versions gives 2 x 3 = 6 jobs: Ubuntu with 18, 20, and 22, and Windows with the same three. Notice that runs-on itself reads from the matrix (${{ matrix.os }}), which is how one definition targets different runners. The count is simply the product of every axis length, so a matrix grows fast - add a third axis of three values and you are at 18 jobs. That multiplication is the point, but it is also the thing to keep an eye on, for reasons covered below under cost.

include: adding specific combinations

Sometimes you want the clean product of your axes plus a few extras that do not fit the grid. The include key adds combinations one at a time. Each entry is an object; the matrix runs your standard product first, then appends the include entries as additional jobs.

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

The base product is still the six OS/Node combinations. The include adds one more job - macOS on Node 22 - without forcing macOS into the full cross product. That is the common shape: test broadly on the cheap Linux and Windows runners, and add a single macOS job for the newest version rather than paying for macOS across every version.

include has a second, subtler behavior worth knowing. If an include entry's keys match an existing combination, its extra keys are merged into that job instead of creating a new one. This lets you attach an extra variable to just one cell of the matrix - for example, marking a specific combination as the one that also uploads coverage.

strategy:
  matrix:
    node: [18, 20, 22]
    include:
      - node: 22
        coverage: true

Here there are still three jobs. The node: 22 job also gets matrix.coverage set to true; the other two have it undefined. You can then gate a step with if: matrix.coverage.

exclude: removing combinations

exclude is the inverse. It removes specific combinations from the product, which is how you avoid pairings that are broken, unsupported, or simply not worth the runner time.

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

The full product is six jobs; excluding Windows on Node 18 leaves five. An exclude entry only needs to name enough keys to identify the combinations to drop - if you exclude on os: windows-latest alone, every Windows job goes. When you use both, GitHub applies exclude first and then include, so include can always add a combination back even if a broader exclude would have removed it.

fail-fast: stopping early, or not

By default a matrix runs with fail-fast: true. The moment any one job in the matrix fails, GitHub cancels all the other in-progress and queued jobs in that matrix. The logic is that if the tests are broken, you probably do not need to burn runner minutes finishing the other 17 combinations to learn the same thing.

strategy:
  fail-fast: false
  matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]

Setting fail-fast: false lets every combination run to completion regardless of the others. Turn it off when you specifically want the full picture: you are debugging a flaky failure that only shows up on certain versions, or you want to know whether a break is isolated to one OS or affects all of them. With fail-fast on, a single early failure hides that information because everything else got cancelled. The tradeoff is runner minutes - fail-fast on is the cheaper, faster default for day-to-day work; off is the diagnostic mode when you need every result.

max-parallel: capping concurrency

By default GitHub runs as many matrix jobs at once as your plan's concurrency allows. max-parallel caps how many run simultaneously within a single matrix.

strategy:
  max-parallel: 2
  matrix:
    node: [18, 20, 22, 23]

This still produces four jobs, but at most two run at a time; the rest queue until a slot frees up. You reach for this when the jobs contend over something shared and external - a rate-limited API, a single test database, a licensed service that only allows a few connections. Left uncapped, all four might hit that shared resource at once and cause flaky failures that have nothing to do with your code. Note the cost: capping parallelism trades wall-clock time for that safety, so only use it when there is a real contention reason.

Using matrix values in steps

Everything under matrix is available as a context inside the job as ${{ matrix.<key> }}. You have already seen it feed runs-on and action inputs; it works anywhere expressions are allowed, including plain shell steps and step-level if conditions.

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node: [20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - name: Show combination
        run: echo "Testing Node ${{ matrix.node }} on ${{ matrix.os }}"
      - name: Lint only once
        if: matrix.node == 22 && matrix.os == 'ubuntu-latest'
        run: npm run lint

The if on the last step is a common pattern: some work does not need to run in every cell of the matrix. Linting is version-independent, so you run it in exactly one combination rather than repeating identical lint output across all four jobs. The matrix context is how a single set of steps adapts itself to which combination it happens to be.

Parallelism beyond the matrix

A matrix is one source of parallel jobs, but it is not the only one. Every job in a workflow runs in parallel by default. If you define three jobs with no relationship between them, GitHub starts all three at once on separate runners. That is the baseline - parallelism is the default, and you have to ask for ordering, not for concurrency.

You create ordering with needs. A job that lists another in needs waits for that job to finish successfully before it starts.

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - run: echo "lint"

  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo "test"

  deploy:
    needs: [lint, test]
    runs-on: ubuntu-latest
    steps:
      - run: echo "deploy"

Here lint and test have no needs, so they run at the same time. deploy needs both, so it waits until both succeed, then runs. This is how you build a pipeline shape - fan out into parallel checks, then converge on a gated step like a deploy - without giving up the free parallelism of the independent jobs. If any job in needs fails, the dependent job is skipped.

Passing data between jobs with outputs

Jobs run on separate runners and share no filesystem, so a later job cannot just read a variable a previous job set. To pass data down a needs chain, a job declares outputs, and the dependent job reads them from the needs context.

jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.derive.outputs.version }}
    steps:
      - id: derive
        run: echo "version=1.4.$GITHUB_RUN_NUMBER" >> "$GITHUB_OUTPUT"

  build:
    needs: setup
    runs-on: ubuntu-latest
    steps:
      - run: echo "Building version ${{ needs.setup.outputs.version }}"

The setup job writes a value to $GITHUB_OUTPUT in a step, exposes it as a job output, and build reads it as needs.setup.outputs.version. This is the standard way to compute something once - a version string, a changed-files list, a deploy decision - and hand it to the jobs that follow, instead of recomputing it in each one. Outputs only flow forward along needs edges, so the job producing them must be a dependency of the job consuming them.

Watch the runner-minute cost

The convenience of matrices makes it easy to forget that every combination is a real job on a real runner, and you are billed for the minutes each one uses. A 3 x 3 matrix is nine jobs; add an OS axis and you are at 18. It compounds quietly, and two multipliers make it worse. First, non-Linux runners are billed at a premium - on GitHub-hosted runners, Windows minutes count at roughly double and macOS at roughly ten times the Linux rate, so a macOS cell is far more expensive than a Linux one. Second, matrices on every push add up fast across a busy repository.

The practical discipline is to test broadly where it is cheap and narrowly where it is not: run the full version spread on Linux, and use include to add only the specific Windows or macOS cells that genuinely need coverage rather than crossing those expensive runners against every version. exclude earns its keep here too, trimming combinations that add cost without adding real signal. A matrix should reflect the coverage you actually need, not every combination that is theoretically possible.

The shape of it

A matrix turns duplicated jobs into data: name the axes that vary, and GitHub expands them into one job per combination, running in parallel. include bolts on extra combinations (or extra variables on one cell), exclude trims combinations you do not want, fail-fast decides whether one failure cancels the rest, and max-parallel caps concurrency when jobs contend over something shared. Inside the steps, ${{ matrix.value }} adapts one definition to each combination. Zoom out and the same theme runs through the whole workflow: jobs are parallel by default, needs imposes ordering, and outputs carry data along those ordering edges. Keep an eye on the runner-minute cost of large matrices, lean on the cheap Linux runners, and reach for include and exclude to keep coverage deliberate. For the workflow fundamentals underneath all of this, see the CI pipelines guide.