How Do I Use include and exclude in a Matrix?

exclude drops combinations from the matrix product and include merges keys into existing combinations or appends new ones. GitHub applies exclude first.

In a GitHub Actions matrix, exclude removes combinations from the product and include either merges extra keys into combinations that already exist or appends combinations the product never produced. GitHub applies every exclude entry first and every include entry afterwards, which is why an include entry can add back a combination that exclude just dropped (GitHub Actions workflow syntax, checked on 2026-08-13).

Answer

Read the block in the order the runner evaluates it rather than the order it appears in the file. Five behaviors cover the whole feature, and three of them belong to include.

KeyWhat you writeEffect on the job set
matrix.<name>A list of valuesThe product of every list is the starting set of jobs
excludeA partial combinationDrops every job whose values match all keys in the entry
include entry with no original matrix keysAn object of extra keysMerges those keys into every combination, count unchanged
include entry whose original keys match a combinationAn object of extra keysMerges into the matching combinations only, count unchanged
include entry that would overwrite an original valueAn object naming a new valueAppended as one new combination, count plus one

The rule under the last two rows is a single sentence in the GitHub reference: an object is added to a combination when none of its key and value pairs overwrite an original matrix value, and an object that fits no combination becomes a new one. Values added by an earlier include entry can be overwritten by a later one, which is what makes default-then-override routing work.

Here is one matrix that uses both keys.

name: test
on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ${{ matrix.runner }}
    strategy:
      fail-fast: false
      matrix:
        suite: [unit, contract, browser]
        arch: [x64, arm64]
        exclude:
          - suite: browser
            arch: arm64
        include:
          - runner: warp-ubuntu-latest-x64-4x
          - arch: arm64
            runner: warp-ubuntu-latest-arm64-4x
          - suite: browser
            runner: warp-ubuntu-latest-x64-8x
          - suite: smoke
            arch: x64
            runner: warp-ubuntu-latest-x64-2x
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/test.sh --suite ${{ matrix.suite }}

Three suites multiplied by two architectures is six combinations. The exclude entry matches browser on arm64 and drops it, leaving five. The first include entry names no original key, so it adds runner to all five. The second matches the two arm64 cells and overwrites the added value there. The third matches the one remaining browser cell and overwrites it again. The fourth names a suite value that no dimension holds, so it cannot merge anywhere and is appended.

suitearchrunnerHow the cell got there
unitx64warp-ubuntu-latest-x64-4xProduct, default runner from include entry 1
unitarm64warp-ubuntu-latest-arm64-4xProduct, runner overwritten by include entry 2
contractx64warp-ubuntu-latest-x64-4xProduct, default runner from include entry 1
contractarm64warp-ubuntu-latest-arm64-4xProduct, runner overwritten by include entry 2
browserx64warp-ubuntu-latest-x64-8xProduct, runner overwritten by include entry 3
smokex64warp-ubuntu-latest-x64-2xAppended by include entry 4
browserarm64noneRemoved by exclude

Six jobs run from a block that reads like five combinations plus some annotations.

Detail

Why an include entry sometimes adds a job

Every surprise in that table comes from one test: can this object be merged without changing a value the product already fixed. { arch: arm64, runner: ... } passes for the two arm64 cells because arch already holds arm64 there and runner is new. { suite: smoke, arch: x64, runner: ... } fails everywhere because no cell has suite set to smoke, so it lands as a sixth job.

Two habits keep the count predictable. Write the include entries that add keys before the ones meant to append combinations, since reading order then matches evaluation order. And keep appended entries complete: an appended combination only carries the keys its own object names, so a step that reads ${{ matrix.arch }} gets an empty string when the appended entry forgot that key.

The ceiling is fixed. A matrix generates a maximum of 256 jobs per workflow run, counted after exclude and include are applied (GitHub Actions limits, checked on 2026-08-13). A generated dimension that grows past that fails the run instead of truncating, which the matrix explosion guide covers.

Routing each combination to a runner label

runs-on reads a matrix value like any other expression, so include is the cleanest place to attach a machine to a combination. The pattern above sets one default label for the whole grid and then overrides the cells that need something else, which keeps the dimensions describing work and the include block describing hardware.

CellRunner labelShape
Default for every combinationwarp-ubuntu-latest-x64-4x4 vCPU, 16 GB, 150GB SSD
Every arm64 cellwarp-ubuntu-latest-arm64-4x4 vCPU, 16 GB, 150GB SSD
The browser cellwarp-ubuntu-latest-x64-8x8 vCPU, 32 GB, 150GB SSD
The appended smoke cellwarp-ubuntu-latest-x64-2x2 vCPU, 8 GB, 150GB SSD

Shapes come from the cloud runners documentation. The same pattern crosses platforms: an include entry can point one cell at warp-macos-15-arm64-6x and another at warp-windows-latest-x64-4x while the steps stay identical. Sizing each cell separately is one of the levers in the guide to speeding up GitHub Actions, and the matrix builds guide works through how wide the grid should be before you tune the labels.

Printing the resolved matrix before the jobs run

Reason about the block once, then confirm it from the run. Add a step that prints the resolved values for the cell it runs in.

      - name: show this cell
        run: |
          echo 'matrix: ${{ toJSON(matrix) }}'
          echo 'cell ${{ strategy.job-index }} of ${{ strategy.job-total }}'

toJSON(matrix) prints every key the cell actually received, including the ones include merged in, and strategy.job-total prints the size of the resolved set (GitHub Actions contexts, checked on 2026-08-13). A cell whose runner key prints empty explains a job that queues forever, and a job-total that differs from your count points straight at an include entry that appended instead of merging.

After the run, the Jobs report aggregates run count and duration percentiles per unique repository, workflow, and job name combination (reports documentation), so a cell that was supposed to disappear shows up as a row that kept collecting runs.

What the edit costs or saves

Both keys move billed minutes. Assume the six cells above run at these durations.

CellDurationBilled minutes per run
unit + x645 minutes5
unit + arm645 minutes5
contract + x644 minutes4
contract + arm644 minutes4
browser + x649 minutes9
smoke + x642 minutes2
Totalwall clock 9 minutes29

The single exclude entry keeps a seventh 9 minute cell out of every run, which is 4,500 minutes a month at 500 pull request runs.

Does include run before or after exclude in a GitHub Actions matrix?

GitHub processes every exclude entry first and every include entry afterwards, so an include entry can add back a combination that exclude removed. Count the product, subtract the exclusions, then apply the inclusions in the order they appear in the file. The build matrix entry covers the surrounding keys, including fail-fast and max-parallel.

Why did my include entry create an extra job instead of adding a key?

An include object is merged into a combination only when none of its keys overwrite an original matrix value. An entry that names a value no dimension holds cannot merge into any combination, so it is appended as one new job. Print ${{ toJSON(matrix) }} in a step to see which keys each cell received, and check strategy.job-total against your own count.

How do I send each matrix combination to a different runner label?

Put a runner key in the include entries and set runs-on: ${{ matrix.runner }}. A bare include object with no original matrix keys sets the default label for every combination, and a later entry that matches a subset overwrites that added value for those cells only. Labels and shapes are listed in the cloud runners documentation, and the matrix explosion guide covers what to do when the grid itself is the problem.

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.