Module 4: The Matrix Versions And Environments

5. `include`, `exclude`, and `fail-fast`

Description

You already know how to generate a grid: two lists multiply into N cells. But a complete grid is rarely exactly what you want. Sometimes there's an extra cell —a combination that doesn't apply, like an old Python version on Windows that no one uses—. Sometimes a special cell is missing that doesn't fit in the product —an extra job that runs only in one combination, with an extra step—. And there's always the question of what to do when a cell turns red: do you cancel the others to save, or let them run to see the whole map? This lesson gives you the three tools for that: exclude, include, and fail-fast.

By the end you'll be able to trim the grid by removing combinations with exclude, add specific cells with include without multiplying the whole matrix, and consciously choose fail-fast's behavior —the default that cancels the sibling cells at the first red, or false to let them run and see all the failures at once—. You're going to see the YAML of each one (honest content), how the CI log would read in each case, and a real local demonstration of fail-fast's cousin: pytest's -x flag, which stops the run at the first failure, so you feel the "stop early vs see everything" trade-off with really executed output.

Connection to the module: lesson 4 gave you the raw grid (3×3 = 9). This one gives you the scalpel to sculpt it: remove what doesn't apply, add what's specific, and decide how it behaves toward a red. Lesson 6 will read the results of the matrix you finish molding here. And lesson 7 will use exclude as one of its tools to trim an inflated matrix down to only what pays off. So here you don't change what the matrix tests (that's lesson 7), but you learn the YAML levers to express it precisely.

The tailor who adjusts a standard-size suit

You buy a suit in a standard size. It fits almost right: the body perfect, but the sleeves a bit long and you're missing an inner pocket where you keep tickets. You don't buy another suit; you go to the tailor. The tailor does three things: removes fabric from the sleeves (excess), adds the missing pocket (a specific piece the standard size didn't bring), and —if they were making several suits in series and one came out badly— decides whether to stop the line to review or let them all come out and then inspect.

Lesson 4's matrix is the standard-size suit: the complete Cartesian product, useful but rarely exact. exclude is removing fabric: it eliminates an excess combination. include is sewing the pocket: it adds a specific cell with its own configuration, without remaking the whole suit. And fail-fast is the decision about the production line: at the first red, do you stop everything or let them run to see how many came out wrong? A good tailor —and a good CI engineer— uses all three with intention, they don't leave the suit as it came from the factory.

exclude: removing a cell from the product

exclude takes the complete grid and subtracts specific combinations. It's declared as a list of dictionaries, each describing the cell you want to remove by its coordinates.

strategy:
  matrix:
    os: [ubuntu-latest, macos-latest, windows-latest]
    python-version: ["3.11", "3.12", "3.13"]
    exclude:
      - os: windows-latest
        python-version: "3.11"      # removes ONLY the (windows, 3.11) cell

The base grid is 3 × 3 = 9 cells. The exclude removes one: the corner (windows-latest, 3.11). Eight remain. Notice the precision: it doesn't remove all of Windows or all of 3.11, only the exact intersection where both coordinates match. The resulting grid:

test (ubuntu-latest, 3.11)    test (ubuntu-latest, 3.12)    test (ubuntu-latest, 3.13)
test (macos-latest, 3.11)     test (macos-latest, 3.12)     test (macos-latest, 3.13)
   [excluded]                 test (windows-latest, 3.12)   test (windows-latest, 3.13)

When do you exclude a cell? When that specific combination doesn't apply or isn't worth it: an old version that no one uses on a specific system, a dependency that has no wheel for that exact pair, an expensive runner (macOS ones usually cost more minutes) where an intermediate version adds nothing. exclude lets you keep "almost the whole product, minus these corners".

include: adding a special cell without multiplying

include does the opposite: it adds cells. But it has a key property that makes it valuable: a cell added with include doesn't multiply the matrix. It's an extra, specific row, which can bring its own additional configuration.

strategy:
  matrix:
    os: [ubuntu-latest]
    python-version: ["3.11", "3.12", "3.13"]
    include:
      - os: ubuntu-latest
        python-version: "3.13"
        coverage: true            # <- an EXTRA key only this cell has

The base matrix is 1 × 3 = 3 cells (Linux, three versions). The include adds one more cell: (ubuntu-latest, 3.13) with an extra key coverage: true the others don't have. Total: four jobs. Inside the job, you could read ${{ matrix.coverage }} to run an extra step —for example, measure coverage— only in that cell. The other three cells don't have that key, so they skip that step.

The difference from adding a value to a list is enormous and worth recording:

Adding a value to a matrix list MULTIPLIES (one more value in a list of 3, with another list of 3, adds 3 cells). Adding a cell with include ADDS ONE (a specific row, with its own config, without touching the product).

That's why include is the tool for "a special cell": the coverage job that only runs in one combination, the experiment with a pre-release version, the build that uploads an artifact only on Linux. If you put coverage as another dimension (coverage: [true, false]), you'd double the whole matrix. With include, you add exactly one cell.

A useful detail: include also serves to expand an existing cell with extra keys without creating a new one, when the combination is already in the product. But its most common use, and the one that matters here, is the one above: adding a specific row.

fail-fast: stop at the first red, or see the whole map

When a matrix cell turns red, GitHub has to decide what to do with the other cells that are still running. That behavior is controlled by fail-fast, and it lives in the strategy block (sibling of matrix).

fail-fast: true — is the default. As soon as a cell fails, GitHub cancels all the sibling cells that are still running. The idea: if you already know the change is broken, why spend minutes finishing the other eight runs? You save time and cost. The price: you only see the first failure; if the bug affects three cells, you'll see one in red and the other two as "cancelled", without knowing they would also have failed.

fail-fast: false — lets all the cells run to the end, even if one has already failed. The idea: you want the whole map —to know exactly which cells pass and which fail—, because that tells you whether the bug is from a specific version or from all. The price: you spend the minutes of the cells you already knew might fail.

strategy:
  fail-fast: false      # let all cells run, even if one fails
  matrix:
    os: [ubuntu-latest, macos-latest, windows-latest]
    python-version: ["3.11", "3.12", "3.13"]

This is how the CI log would read in each case, with a bug that breaks the three Windows cells. With fail-fast: true (default):

tests · push to main   (fail-fast: true)
  ✓ test (ubuntu-latest, 3.11)     ✓ test (ubuntu-latest, 3.12)     ✓ test (ubuntu-latest, 3.13)
  ✓ test (macos-latest, 3.11)      ✓ test (macos-latest, 3.12)      ✓ test (macos-latest, 3.13)
  ✗ test (windows-latest, 3.11)    ⊘ test (windows-latest, 3.12)    ⊘ test (windows-latest, 3.13)
                                     (cancelled at the first red)

You see one Windows cell red and two cancelled (). You know Windows fails, but you didn't confirm that all three Windows versions fail —it could be just 3.11—. With fail-fast: false:

tests · push to main   (fail-fast: false)
  ✓ test (ubuntu-latest, 3.11)     ✓ test (ubuntu-latest, 3.12)     ✓ test (ubuntu-latest, 3.13)
  ✓ test (macos-latest, 3.11)      ✓ test (macos-latest, 3.12)      ✓ test (macos-latest, 3.13)
  ✗ test (windows-latest, 3.11)    ✗ test (windows-latest, 3.12)    ✗ test (windows-latest, 3.13)

Now you see the whole map: the three Windows cells red, the six Linux/macOS ones green. The diagnosis is immediate and precise: the bug is Windows's, on all versions —not from a single version—. That complete map is worth the extra minutes when you're hunting what a failure depends on.

The practical rule: fail-fast: false when you want to diagnose (see all the cells that fail to locate the pattern), the default true when you want fast and cheap feedback (it's enough to know something broke so it doesn't get merged). Many teams use false on the main branch, where the diagnosis matters, and true on work branches, where they only want the quick signal.

max-parallel: how many cells at once

A close cousin, to complete the picture. By default GitHub runs as many cells in parallel as your available runners allow. max-parallel limits that number:

strategy:
  max-parallel: 3       # at most 3 cells running at once
  matrix:
    python-version: ["3.11", "3.12", "3.13", "3.14"]

With nine cells and max-parallel: 3, they run three at a time. What's the point of limiting it? To not saturate a runner quota shared with other projects, or to not hit an external resource the suite touches all at once (a test API with a request limit). The cost: the matrix takes longer in total, because not everything runs at once. It's a "bandwidth" lever, not a "what gets tested" one.

The local cousin of fail-fast: pytest's -x flag

We don't have a runner to see fail-fast cancel cells for real, but pytest has the same trade-off one level down, within a single run, and that one we can execute. The -x flag (or --exitfirst) tells pytest: "stop as soon as a test fails, don't continue with the others". It's, at the level of tests within a job, what fail-fast is at the level of cells within a matrix: stop early to save, in exchange for not seeing all the failures.

Worked example

Let's run a batch of tests that includes a broken one in the middle, with -x, to see pytest stop:

python -m pytest -x tests/test_pricing.py tests_red/test_report_path_bug.py tests/test_refunds.py

What to expect. On Python 3.14.0, measured for real (the broken test compares a path against a Windows separator, so on macOS it fails on purpose):

=================================== FAILURES ===================================
_____________________ test_report_path_hardcoded_separator _____________________

    def test_report_path_hardcoded_separator():
        # BUG: compares against a '/' separator written by hand. Passes on POSIX,
        # breaks on Windows. Here we force it to fail to see a RED cell.
        path = os.path.join("reports", "2026-08", "daily.txt")
>       assert path == "reports\\2026-08\\daily.txt"  # Windows separator, fails on macOS
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       AssertionError: assert 'reports/2026-08/daily.txt' == 'reports\\2026-08\\daily.txt'

tests_red/test_report_path_bug.py:8: AssertionError
=========================== short test summary info ============================
FAILED tests_red/test_report_path_bug.py::test_report_path_hardcoded_separator - AssertionError: assert 'reports/2026-08/daily.txt' == 'reports\\2026-08\\da...
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
========================= 1 failed, 3 passed in 0.02s =========================

Read the second-to-last line: !!! stopping after 1 failures !!!. pytest ran the three tests of test_pricing.py (the 3 passed), reached the broken test, failed, and stopped there —it never ran the test_refunds.py tests that came after—. That's exactly the spirit of fail-fast: true: at the first red, don't spend more, cut. The summary 1 failed, 3 passed confirms it: of the seven tests in the list, only four ran before stopping.

If you'd run the same without -x, pytest would have run all seven, showing you all the ones that pass and all the ones that fail —the "whole map", like fail-fast: false—. Same trade-off, one level down: -x is to tests what fail-fast is to matrix cells. Feeling one locally gives you intuition for the other in CI.

An honest clarification so as not to confuse layers: -x stops the tests within a job; fail-fast cancels cells (jobs) within a matrix. They're different mechanisms, at different levels, that share the same philosophy —stop early vs see everything—. Don't mix them in the same YAML expecting one to do the other's job.

Common mistakes

Using an extra dimension when you wanted include. What happens: you want a single coverage job on (ubuntu, 3.13), and you add it as a coverage: [true, false] dimension. That doubles the whole matrix: each cell now exists with coverage: true and with coverage: false, twice the jobs. Why it happens: "adding an option" gets confused with "adding a cell". How to spot it: if your cell count doubled when you wanted to add a single special case, you used a dimension where you wanted include. How to fix it: put the special cell in include, which adds one row with its own config, without multiplying.

exclude with coordinates that don't exist. What happens: you exclude os: windows-latest, python-version: "3.10", but your version list is ["3.11", "3.12", "3.13"] —3.10 isn't there—. The exclude removes nothing (there's no such cell), and you still have all nine, believing you removed one. Why it happens: a typo in the version, or copying an exclude from another project. How to spot it: count the cells after the exclude; if the number didn't go down, your coordinates don't match any real cell. How to fix it: make sure each exclude coordinate is a value that's really in the matrix lists.

Leaving fail-fast: true when you're diagnosing what a failure depends on. What happens: a test fails oddly and you want to know if it's only on 3.11 or on all versions, but with the default true, the first red cell cancels the others and you never see the whole pattern. Why it happens: the default cancels to save, which is good for fast feedback but bad for diagnosing. How to spot it: you see one red cell and several "cancelled" ones, and you can't conclude which versions it fails on. How to fix it: put fail-fast: false while diagnosing, so all run and give you the whole map of reds and greens; go back to the default when you finish.

Exercises

Exercise 1 — Count the cells after sculpting. For each matrix, say how many jobs result: (a) os: [ubuntu, macos, windows] × python-version: ["3.11", "3.12", "3.13"] with exclude of (windows, 3.11) and (macos, 3.11); (b) os: [ubuntu] × python-version: ["3.11", "3.12"] with an include of (ubuntu, 3.13, coverage: true); (c) python-version: ["3.11", "3.12", "3.13"] (a single dimension) with an include of (3.14, experimental: true).

See solution
  • (a) 9 − 2 = 7 jobs. The base product is 3 × 3 = 9; the exclude removes two specific cells —(windows, 3.11) and (macos, 3.11)—, leaving seven.
  • (b) 2 + 1 = 3 jobs. The base is 1 × 2 = 2 cells (Linux, 3.11 and 3.12); the include adds one specific cell —(ubuntu, 3.13) with coverage: true—, without multiplying. Total: three.
  • (c) 3 + 1 = 4 jobs. A single dimension of three values gives three cells; the include adds one more row —3.14 with experimental: true—, without touching the others. Total: four.

The recurring rule: exclude subtracts cells from the product; include adds specific cells to the product. Neither multiplies. Multiplying is done only by adding a value to a dimension list.

Exercise 2 — Choose fail-fast for the goal. For each situation, say whether fail-fast: true (default, cancels siblings) or fail-fast: false (lets all run) is best and why: (a) a push to a work branch where you only want to know quickly if something broke, without overspending; (b) you're investigating whether a new bug affects only Windows or all three systems; (c) the main branch's pipeline, where you want the most complete report possible before approving a merge.

See solution
  • (a) fail-fast: true (the default). On a work branch, the signal you want is binary —"does it pass or not?"—; as soon as a cell fails, you already know it shouldn't be merged, so cancelling the others saves minutes without losing information you need. Fast and cheap feedback.
  • (b) fail-fast: false. You're diagnosing what the failure depends on, and for that you need the whole map: to see whether all three Windows cells fail (Windows bug) or only one version (version bug). With true, the first red would cancel the others and you'd never see the pattern. Here the extra minutes buy the diagnosis.
  • (c) fail-fast: false. On the main branch you want the most complete report before approving: to know all the cells that fail, not just the first, so you don't fix one and discover another on the next push. The extra cost is justified by the branch's importance.

The mechanical rule: true for fast/cheap feedback, false for diagnosis/complete report. Many teams combine: true on work branches, false on main.

Exercise 3 — Translate a requirement to exclude/include. A team wants: to test Python 3.11, 3.12, and 3.13 on Linux and Windows; but a dependency has no wheel for Windows with 3.11, so that cell can't run; and also they want a single extra job that measures coverage, only on Linux with 3.13. Write the strategy.matrix section that meets exactly that and say how many cells result.

See solution
strategy:
  matrix:
    os: [ubuntu-latest, windows-latest]
    python-version: ["3.11", "3.12", "3.13"]
    exclude:
      - os: windows-latest
        python-version: "3.11"        # the dependency has no wheel for this pair
    include:
      - os: ubuntu-latest
        python-version: "3.13"
        coverage: true                # single extra coverage job

Cell count: the base is 2 × 3 = 6; the exclude removes (windows, 3.11)5; the include adds a specific coverage row → 6 jobs total. The five product cells run the normal suite; the sixth, the include one, additionally runs the coverage step (reading ${{ matrix.coverage }}). Notice the coverage cell reuses (ubuntu, 3.13), which is already in the product: with include, a "normal" (ubuntu, 3.13) cell and the extra one with the coverage key coexist. The requirement is expressed exactly: exclude for the impossible combination, include for the special job.

Summary and next step

In this lesson you learned to sculpt the grid with three tools. exclude subtracts specific cells from the product —to remove a combination that doesn't apply or isn't worth it— by their exact coordinates. include adds specific cells, with their own extra configuration, without multiplying the matrix —the tool for the special coverage job or the version experiment—. And fail-fast decides what happens toward a red: the default true cancels the siblings for fast and cheap feedback; false lets them run to give you the whole map when you diagnose. We also saw max-parallel, the lever of how many cells run at once.

You recorded the key distinction: adding a value to a list multiplies, include adds one, exclude subtracts. And you felt the fail-fast trade-off by executing its local cousin, pytest's -x flag: running a batch with a broken test in the middle, pytest stopped with stopping after 1 failures and 1 failed, 3 passed, without reaching the later tests —stop early to save, in exchange for not seeing everything—, exactly the fail-fast philosophy one level down.

Before moving on you should be able to: write a correct exclude and include; count the resulting cells; explain why include doesn't multiply; choose fail-fast based on whether you want fast feedback or complete diagnosis; and relate pytest's -x to the matrix's fail-fast without confusing the layers.

What's next, in lesson 6, is reading what this matrix produces: the N results. How GitHub names each cell, how an all-green grid or one with a single red cell reads, and what that red cell tells you —which version or system the bug lives in— to locate it and, from there, reproduce it locally by matching that environment.

Resources