Skip to content

fail-fast vs continue-on-error in Matrix


Nine jobs running in parallel raises an obvious question: what happens when one of them fails? Does the whole matrix stop, or do the other eight keep going? GitHub Actions has a default answer, and it’s more aggressive than most people expect the first time they hit it. This chapter clears the exact doubt.

What fail-fast Does By Default

strategy.fail-fast defaults to true, whether you write it explicitly or not. Here’s what that means in practice: the moment any job in the matrix fails, every other job that’s still queued or in progress gets cancelled — not marked as skipped, actually cancelled mid-run.

This is often exactly what you want. If your Windows 3.12 leg just failed, you probably don’t need to burn eight more runner-minutes finding out whether the other eight also would have passed — you already know something’s broken, and you can go fix it. That’s the whole point of fail-fast: stop wasting time once you have your answer.

But it has a real cost while you’re actively debugging a matrix: if the first leg to fail happens to be the one you weren’t trying to investigate, the runs you actually wanted to inspect get cancelled out from under you before they finish.

Hands-On: Watching Fail-Fast Cancel A Healthy Matrix

See it happen for yourself. Add a step to matrix-tests.yml that deliberately fails on exactly one leg:

- name: Simulate A Failure On One Leg
  if: matrix.os == 'windows-latest' && matrix.python-version == '3.14'
  run: exit 1

Insert it right after “Run Tests” so the rest of that leg’s work has already completed:

name: Matrix Tests

on: [push, workflow_dispatch]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        python-version: ["3.12", "3.13", "3.14"]
        os: [windows-latest, ubuntu-latest, macos-latest]

    steps:
      - name: Checkout repo
        uses: actions/checkout@v7

      - name: Setup Python
        uses: actions/setup-python@v7
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install pytest
        run: python -m pip install --upgrade pip pytest

      - name: Run Tests
        run: python -m pytest .

      - name: Simulate A Failure On One Leg
        if: matrix.os == 'windows-latest' && matrix.python-version == '3.14'
        run: exit 1

Push it:

git add .github/workflows/matrix-tests.yml
git commit -m "Add deliberate failure to demonstrate fail-fast"
git push

Watch the Actions tab. As soon as the Windows/3.14 leg hits that failing step, you’ll see the other eight jobs — including ones that had nothing to do with Windows or 3.14 — get cancelled mid-flight, some before they even finished installing dependencies. That’s fail-fast: true, doing exactly what it’s designed to do: the instant one leg proves something is broken, it stops spending time on the rest.

You can see it in Annotations below your jobs run:

test (3.14, windows-latest)
Process completed with exit code 1.
test (3.13, ubuntu-latest)
The strategy configuration was canceled because "test._3_14_windows-latest" failed

Turning It Off Entirely

If you’d rather see the full picture every time — every OS, every version, pass or fail — set fail-fast: false:

strategy:
  fail-fast: false
  matrix:
    python-version: ["3.12", "3.13", "3.14"]
    os: [windows-latest, ubuntu-latest, macos-latest]

Push again with this change and the same deliberate failure still in place, and all nine legs now run to completion — eight green, one red — regardless of order. This is the right call while you’re actively debugging a matrix and need to see every result, or for a project where one platform’s quirks genuinely shouldn’t block feedback on the others.

What continue-on-error Does — And Where It’s Different From fail-fast

fail-fast is all-or-nothing: it controls whether any failure cancels the whole matrix. continue-on-error works at the level of a single job, and it answers a narrower question: should this specific job’s failure count as a real failure at all?

Set on a job, continue-on-error: true means that job can fail — show a red X in the log — without failing the overall workflow run. Combined with a matrix, you can scope it to a specific slice using an expression:

continue-on-error: ${{ matrix.python-version == '3.14' }}

That’s the more common real-world shape than an all-or-nothing switch: 3.14 is new enough that you’re genuinely unsure whether your dependencies fully support it yet, so a failure there is informational, not a blocker. 3.12 and 3.13 are your supported versions — a failure on either of those is a real problem.

The Part That Surprises People: fail-fast And continue-on-error Together

Here’s the detail worth being precise about, because it’s easy to assume continue-on-error also disables fail-fast — it doesn’t.

When you combine the two, GitHub treats a job with continue-on-error: true as not really failed for the purposes of fail-fast’s cancellation decision. So:

  • A leg with continue-on-error: true (your 3.14 legs) fails → the other legs are not cancelled, because that failure doesn’t count as a matrix-wide failure signal.
  • A leg with continue-on-error: false (your 3.12 or 3.13 legs) fails → fail-fast: true still cancels every other in-progress or queued job, exactly as before.

In other words: you get fail-fast behavior for the versions you actually support, and a quiet, non-blocking failure for the one you’re still watching. That’s a meaningfully different result from turning fail-fast off altogether — with fail-fast: false, nothing cancels anything, ever. With this combination, a real regression on 3.12 or 3.13 still stops the matrix immediately; only 3.14’s uncertainty is allowed to fail without consequence.

Hands-On: Treating 3.14 As Experimental

Update the workflow to scope continue-on-error to the 3.14 legs, keeping fail-fast: true (or simply omitting it, since that’s the default):

.github/workflows/matrix-tests.yml
name: Matrix Tests

on: [push, workflow_dispatch, pull_request]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    continue-on-error: ${{ matrix.python-version == '3.14' }}
    strategy:
      fail-fast: true
      matrix:
        python-version: ["3.12", "3.13", "3.14"]
        os: [windows-latest, ubuntu-latest, macos-latest]

    steps:
      - name: Checkout repo
        uses: actions/checkout@v7

      - name: Setup Python
        uses: actions/setup-python@v7
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install pytest
        run: python -m pip install --upgrade pip pytest

      - name: Run Tests
        run: python -m pytest

      - name: Simulate A Failure On One Leg
        if: matrix.os == 'windows-latest' && matrix.python-version == '3.14'
        run: exit 1

Push it:

git add .github/workflows/matrix-tests.yml
git commit -m "Treat Python 3.14 as experimental via continue-on-error"
git push

Watch the run. The Windows/3.14 leg fails and shows a red X — but this time, the other eight legs run to completion undisturbed, and the overall workflow still reports success, since the one failure was on a leg marked continue-on-error: true.

Now remove the deliberate failure — it did its job:

If you know, how to git revert (better because it preserves history):

git revert HEAD~1
# Resolve conflicts, git add, git revert --continue (Assumed you know git)
git push

If you don’t know Git much, you can simply delete previous two commits:

git reset --hard HEAD~2
git push --force-with-lease

Either way works for our demonstration.

You’ve now got a matrix that tests real cross-platform, cross-version behavior and fails in exactly the way you want, for exactly the versions you want. The next chapters turn to a different problem: once a run finishes and the VM disappears, how do you get anything — a test report, a log, a file — back out of it?

Last updated on