When a Matrix Grows Too Wide

A GitHub Actions matrix multiplies every dimension into jobs. Price the width, prune the pull request grid with exclude and a computed matrix, run it nightly.

Last verified:

A GitHub Actions matrix creates one job per combination of the lists under strategy.matrix, so every dimension you add multiplies the job count, and the billed minutes, by the length of its list. A grid that reached 216 jobs is usually five dimensions doing the work of two dimensions plus one scheduled run.

This guide covers how to price the width you already pay for, the three tools that prune it, a workflow that runs a narrow matrix on pull requests and the complete grid nightly, and the monthly arithmetic for the change. Matrix structure and sharding live in the guide to building a matrix that scales, and the rest of the bill lives in the hub on reducing GitHub Actions costs.

Diagnosis

Count the product before changing anything

The generated count is the product of every list under strategy.matrix, minus the combinations exclude removes, plus the include entries that match no existing combination (workflow syntax for strategy.matrix, checked on 2026-08-13). Read the block that way rather than counting the lines in the file, because one include object can merge keys into 12 cells without adding a job, and the answer on include and exclude walks through each case.

One hard stop sits above the arithmetic. A matrix generates at most 256 jobs per workflow run, and a product above that fails the run before any job starts (GitHub Actions usage limits, checked on 2026-08-13). Teams usually meet that ceiling by accident, when a routine third value is added to a dimension that already multiplies four other lists. The answer on how many jobs a matrix can fan out to covers the cap on its own.

Price each dimension

Width has a unit price, and the price is the whole current grid. The table prices one definition as dimensions accumulate, with every cell taking 8 minutes on warp-ubuntu-latest-x64-4x at $0.008 per minute from the cloud runners documentation, checked on 2026-08-13. Every cell is priced on the same label here so the width is the only variable.

Dimensions in the definitionProductJobsBilled minutes at 8 minutes per cellCost per run at $0.008 per minute
pkg with 3 values3324$0.19
plus node with 3 values3 x 3972$0.58
plus runner with 3 values3 x 3 x 327216$1.73
plus database with 2 values3 x 3 x 3 x 254432$3.46
plus shard with 4 values3 x 3 x 3 x 2 x 42161,728$13.82

Read it as marginal cost. The two value database dimension adds $1.73 per run, which is exactly what the entire grid cost one row earlier, and it does that for every run from then on. The last row also sets up the failure: one more two value dimension takes the product to 432, which is above the 256 job cap, so the run stops generating jobs and starts failing outright.

Find the dimensions nobody reads

A dimension earns its multiplier when its cells can disagree. The test is failure history rather than intuition: pull the per job rows for the workflow and look for a job name that has never failed while its siblings were green. A node value that has produced no independent failure in six months is answering a question the team stopped asking.

Duration percentiles catch the second pattern. The Jobs report gives duration P75 and P90 for every repository, workflow, and job name combination, with CSV export (reports documentation). Cells whose percentiles sit within noise of each other, with logs that contain the same steps, are paying setup three times to produce one result. On GitHub-hosted runners, pull the same rows from the workflow jobs API and compute the percentiles yourself.

Three signals are usually enough to pick the dimension to cut first:

  • The dimension multiplies the largest number of cells and has the fewest independent failures.
  • Its values are environment choices, such as a database minor version, rather than something a user runs.
  • Removing it leaves a targeted test that covers the same edge case in one job.

Fix

Three tools, in the order that removes the most jobs for the least review risk.

Trim the corners with exclude. exclude drops every combination whose values match all the keys in the entry, so a single entry removes a whole row or column of the product. Windows plus the worker package, or the oldest runtime plus the newest package, are corners that carry little risk and full cost. One exclude entry against a three value dimension removes three jobs, and the entry stays readable next to the lists it prunes.

Compute the matrix at run time. A hardcoded grid cannot know that a branch touched one package. Emit the matrix as JSON from a small planning job and read it with fromJSON, so the pull request width tracks the diff instead of the repository (expressions reference for fromJSON, checked on 2026-08-13). This is the edit that turns a fixed 24 cell bill into a bill proportional to the change. When the whole matrix comes from an expression, exclude moves inside the generated JSON, since the expression replaces the entire matrix mapping.

Move the full grid to a schedule. The complete product still has to run, just not on every push. A scheduled workflow runs the whole grid once a day on the default branch, which keeps the compatibility answer current at one run per day rather than one per pull request. The guide to nightly build jobs covers who gets notified when that run goes red, which is the part teams skip.

Then give what remains somewhere to land. A mixed grid resolves against one catalog and the edit is the runs-on label. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, so a nightly grid of 24 cells starts as 24 cells rather than four waves of six.

Configuration

This workflow plans the matrix in a first job, then builds. Pull requests get one runner label, one Node version, and only the packages the branch changed. The scheduled run gets the full product with one exclude entry.

name: build

on:
  pull_request:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:

jobs:
  plan:
    runs-on: warp-ubuntu-latest-x64-2x
    outputs:
      matrix: ${{ steps.build.outputs.matrix }}
      count: ${{ steps.build.outputs.count }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - id: build
        env:
          EVENT: ${{ github.event_name }}
          BASE: ${{ github.base_ref }}
        run: |
          if [ "$EVENT" = "pull_request" ]; then
            base=$(git merge-base "origin/$BASE" HEAD)
            pkgs=$(git diff --name-only "$base"...HEAD \
              | awk -F/ '$1 == "packages" { print $2 }' | sort -u \
              | jq -Rsc 'split("\n") | map(select(length > 0))')
            matrix=$(jq -nc --argjson pkg "$pkgs" \
              '{runner: ["warp-ubuntu-latest-x64-4x"], node: ["22"], pkg: $pkg}')
          else
            matrix=$(jq -nc '{
              runner: [
                "warp-ubuntu-latest-x64-4x",
                "warp-macos-latest-arm64-6x",
                "warp-windows-latest-x64-4x"
              ],
              node: ["20", "22", "24"],
              pkg: ["api", "web", "worker"],
              exclude: [{runner: "warp-windows-latest-x64-4x", pkg: "worker"}]
            }')
          fi
          echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
          echo "count=$(echo "$matrix" | jq '.pkg | length')" >> "$GITHUB_OUTPUT"

  build:
    needs: plan
    if: needs.plan.outputs.count != '0'
    strategy:
      fail-fast: ${{ github.event_name == 'pull_request' }}
      matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci
      - run: npm run build --workspace packages/${{ matrix.pkg }}
      - run: npm test --workspace packages/${{ matrix.pkg }}

What each piece does:

  • The scheduled branch generates 3 runner labels x 3 Node versions x 3 packages, which is 27 cells, and the exclude entry removes the 3 Windows plus worker combinations, leaving 24 jobs.
  • The pull request branch generates one cell per changed package. A branch that touches packages/api alone produces 1 job against the 24 the same repository used to run.
  • git merge-base plus the three dot diff compares the branch against the point it forked from, so a merge into the base branch does not widen the matrix with files the branch never touched.
  • The env block passes github.base_ref into the script as a variable rather than interpolating it into the shell line, which keeps a branch name from becoming part of the command.
  • exclude lives inside the generated JSON. Setting matrix to an expression replaces the whole mapping, so a sibling exclude key under matrix has nothing to attach to.
  • The strategy key reads the github context (contexts reference, checked on 2026-08-13), so fail-fast is true for the pull request gate and false for the nightly compatibility report without duplicating the job.
  • if: needs.plan.outputs.count != '0' skips the build job when no package changed. A skipped job reports as skipped to branch protection, while a job that generates zero cells leaves a required check with nothing to report, so keep the required check on a job with a fixed name.

The three labels in that file resolve to these machines, from the cloud runners documentation, checked on 2026-08-13.

runs-on labelOSvCPURAMStorageUSD per minute
warp-ubuntu-latest-x64-2xUbuntu 24.0428 GB150GB SSD$0.004
warp-ubuntu-latest-x64-4xUbuntu 24.04416 GB150GB SSD$0.008
warp-macos-latest-arm64-6xmacOS 15622 GB120GB SSD$0.08
warp-windows-latest-x64-4xWindows Server 2022416 GB256GB SSD$0.016

Cost or Time Model

Price the nightly grid first, because it is the run that keeps the full product. Assumptions: 24 cells as generated above, 8 minutes per cell, no retries, per minute billing, and the catalog rates in the table above.

Runner labelCellsBilled minutesRate per minuteCost per run
warp-ubuntu-latest-x64-4x972$0.008$0.58
warp-macos-latest-arm64-6x972$0.08$5.76
warp-windows-latest-x64-4x648$0.016$0.77
Total24192$7.10

Now run that same grid on every pull request instead. Assume 20 pull request runs a day across 22 working days, so 440 runs a month, and an average of 2 changed packages per branch.

ConfigurationCells per runRuns per monthBilled minutes per monthCost per month
Full grid on every pull request2444084,480$3,125.76
Narrow pull request grid24407,040$56.32
Nightly full grid24305,760$213.12
Pruned total12,800$269.44

The pruned configuration bills 12,800 minutes a month against 84,480 for the same coverage on every push, a difference of $2,856.32 at these rates. The Linux portion of that carries a list price comparison: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB), which is 33 percent lower list price, arithmetic (0.012 - 0.008) / 0.012, with the GitHub list price checked on 2026-08-13 (GitHub Actions minute multipliers). The 7,040 pull request minutes cost $56.32 at the first rate and $84.48 at the second.

Wall clock moves less than the invoice does. When every cell of the wide grid already started at once, pruning the pull request matrix returns queue pressure and billed minutes rather than minutes of review latency. The real trade is detection time: a break in the Node 20 plus Windows corner now surfaces in the next morning's scheduled run instead of in the pull request that caused it. Price that against the table before cutting a dimension that has produced independent failures.

Per minute rates for every size are on the pricing page.

FAQ

Why does my GitHub Actions matrix create so many jobs?

The job count is the product of every list under strategy.matrix, so a definition with 3 packages, 3 Node versions, and 3 runner labels generates 27 jobs before any exclude entry is applied. Each dimension added multiplies the whole grid again, which is why a matrix that started at 9 jobs reaches 216 after two more dimensions.

How do I reduce the number of jobs a matrix generates?

Three edits, in order of how much they remove. Drop exclude entries on the corners of the product that carry no risk, compute the pull request matrix from the packages a branch actually changed so the width tracks the diff, and move the full grid to a scheduled run so the complete product is paid once a day instead of once per push.

What breaks when a matrix gets too wide?

A matrix generates at most 256 jobs per workflow run, and a definition whose product lands above that fails the run before any job starts. Below the cap, the failure is quieter: billed minutes grow with the width while the answers stay the same, and cells queue in waves once the fan-out is wider than the runners available behind the label.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.