GitHub Actions matrix parallel jobs: what each leg really costs
A GitHub Actions matrix parallel fan-out turns one job definition into one job per combination, up to 256 of them in a workflow run, and each of those jobs starts on a machine that has never seen your repository. On the runner we measured, getting to the first line of your own work cost 26.8 seconds, so a sixteen-way matrix spends more than seven minutes of billed time before any of it does anything useful.

A matrix is the cheapest parallelism in Actions to write and the easiest to over-buy. Four lines turn one job into twelve, and the wall clock improves by much less than twelve times because every leg pays the whole cost of being a job again.
This page is the three things worth knowing before you widen one: how the matrix expands, what caps it, and what each extra leg actually costs you in time and in money.
How the matrix expands
Every key under matrix is a list, and the matrix is the product of all of them. Two operating systems and three Node versions is six jobs, not five and not two. Add a fourth Node version and you have eight, and the workflow file grew by one word.
include adds combinations, or adds keys to combinations that already exist, and exclude removes them, which is how you get "every version on Linux, but only the newest on macOS" without writing the product out. GitHub caps the result: a job matrix can generate a maximum of 256 jobs per workflow run, on hosted and self-hosted runners alike.
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
node: [20, 22, 24]
exclude:
- os: macos-latest
node: 20
include:
- os: ubuntu-latest
node: 24
coverage: truefail-fast and max-parallel are the two dials
fail-fast is on by default, and it means the first leg to fail takes the rest with it: everything in progress or queued in that matrix is stopped. Right for a gate you need only a yes or no from, wrong for a test matrix, where you wanted to know whether the failure is one version or all of them. Turning it off costs the minutes the other legs run and buys the answer in one run instead of two.
max-parallel limits how many legs run at once without changing how many run in total. It is the dial for a matrix that hammers a shared resource: a staging database, a rate-limited API, a license server. It is also how you stop one workflow eating your whole concurrency allowance while everything else in the organization queues behind it.
strategy:
fail-fast: false # every leg reports, even after one fails
max-parallel: 4 # at most four at a time, all of them eventually
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8]The ceiling is your plan, not your YAML
A matrix of 40 legs on a plan that allows 20 concurrent jobs is not 40 jobs in parallel. It is 20, then 20 more, and your wall clock is two rounds rather than one. The limit is per account across every workflow you are running, so the other pull requests open at the same time are spending the same budget.
The macOS numbers are the ones that surprise people, because they are small and they do not scale with the plan until Enterprise. A five-way macOS matrix is the entire allowance on Free, Pro and Team.
| Plan | Concurrent jobs | Concurrent macOS jobs |
|---|---|---|
| Free | 20 | 5 |
| Pro | 40 | 5 |
| Team | 60 | 5 |
| Enterprise | 500 | 50 |
Every leg pays the fixed cost again
This is the part the graph does not show you. Each leg queues for its own runner, checks out again, installs its toolchain again and restores its dependencies again. We measured those costs on a runner one at a time, and the three that a typical job pays before its own first command add up to 26.8 seconds.
Multiply that by the width, because a matrix does. Two legs is 54 seconds of setup, eight legs is three and a half minutes, sixteen is over seven. None of it is your build, all of it is billed, and rounding makes it worse: GitHub rounds each job up to the nearest minute, so sixteen legs that each finish in forty seconds are billed as sixteen minutes.
| Fixed cost, measured once | Duration | Paid per leg |
|---|---|---|
apt-get update | 23.85 s | Yes, unless it is in the image |
| Extract a toolchain not in the tool cache | 2.16 s | Yes, on every leg |
git clone --depth 1, the checkout default | 0.83 s | Yes, on every leg |
| A toolchain already in the tool cache | 0.009 s | Yes, and it is free |
npm ci, 414 packages, cache restored | 2.39 s | Yes, on every leg |
Where width stops paying
The arithmetic is not complicated and almost nobody does it. Wall clock for a matrix is the fixed cost plus the work divided by the width. Billed time is the fixed cost times the width, plus the work. The fixed cost is the only term that grows with width, and it grows linearly while the saving shrinks. Count queue time inside it: a leg that waits for a runner is a leg that has not started, and once the width crosses your plan ceiling the later legs wait for the earlier ones to finish.
Put your own two numbers in. If setup is 30 seconds and the work is 30 seconds, going from one leg to four takes the wall clock from 60 seconds to 37.5 and the bill from 60 seconds to 150. If setup is 30 seconds and the work is 20 minutes, the same change is obviously worth it. The question is never "is a matrix good", it is "is my work large compared with my setup".
# setup = queue + checkout + toolchain + install
# wall clock = setup + work / legs
# billed time = setup * legs + work
#
# leg 2 removes work/2. leg 16 removes work/240.
# every leg adds the same setup. that is the whole knee.Make the legs cheaper before you add more
Every second off the fixed cost is multiplied by the width, which makes setup the highest-leverage thing in a wide matrix. The one that dominated our measurements is a system package install: an index refresh alone was 23.85 seconds, and moving those packages into a prebuilt image removes it from every leg at once.
After that, ask each leg for a toolchain version the runner image already carries, which was 9 milliseconds against 2.16 seconds for one it has to unpack, and cache dependencies so the install is a restore. Then narrow the matrix itself: most matrices carry combinations nobody reads the result of, and an exclude is free speed.
jobs:
test:
container: ghcr.io/acme/ci-base:2026-09 # no apt-get on any leg
strategy:
fail-fast: false
matrix:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: npmWhat we ran, so you can disagree with it
No new runner jobs were run for this page. The per-leg costs are quoted from two scripts committed for earlier pages in this area, job-f.sh and job-a.sh, both run once on Latchkey latchkey-small runners on 20 September 2026 and both committed with their unedited logs and a status file carrying the job id, the runner size and the exit code.
The multiplication is arithmetic on those measurements and is not itself a measurement: we did not run a sixteen-way matrix and time it. A real matrix also pays queue time per leg, which varies with your plan and with what else is running, and which nothing in this page measures. And the apt-get install row in that log installed nothing, because the package was already on the image, so it measures the cost of asking rather than the cost of installing.
Frequently asked questions
How many jobs can a GitHub Actions matrix create?
What does fail-fast do in a matrix strategy?
How do I limit how many matrix jobs run at once?
max-parallel, which caps how many legs run simultaneously without reducing how many run in total. It is the right tool when the legs share something that cannot take the load, such as a staging database or a rate-limited API, and it also keeps one workflow from consuming the whole account concurrency allowance.Why is my matrix slower than the number of legs suggests?
Related guides
References
- GitHub Docs: running variations of jobs in a workflow (verified 2026-09-20)
- GitHub Docs: Actions limits, concurrency and the matrix cap (verified 2026-09-20)
- GitHub Docs: workflow syntax, jobs and strategy (verified 2026-09-20)
- GitHub Docs: contexts, including the strategy context (verified 2026-09-20)
- GitHub Actions documentation