Matrix-Testing mypy Across Python Versions

TL;DR — A single mypy run only analyzes your code against one target interpreter, so version-specific typing errors slip through. Put a GitHub Actions strategy.matrix over python-version and pass mypy --python-version=X on each leg so mypy analyzes as if it were that release. Set fail-fast: false so every version reports independently. This surfaces new-union syntax, PEP 695 generics, and stdlib stub drift that a one-version pipeline never sees.

If your library advertises support for Python 3.9 through 3.13, then “mypy passes” is only true for the one interpreter your CI happened to install. The X | Y union operator, type statements from PEP 695, and even the shape of typeshed stubs all shift between releases. A matrix run is the type-checking analogue of a test matrix: it proves the annotations hold on every version you ship.

mypy matrix fan-out across Python versions A single workflow expands into four parallel legs; each installs one interpreter and runs mypy with a matching python-version target, and fail-fast false keeps every leg reporting. strategy.matrix fail-fast: false 3.9 leg --python-version 3.9 3.11 leg --python-version 3.11 3.12 leg --python-version 3.12 3.13 leg --python-version 3.13 Each leg reports its own errors; no leg cancels another
The matrix fans one workflow into per-version legs, each pinning a distinct mypy target so version-specific errors surface independently.

The matrix workflow

Two knobs work together. matrix.python-version controls which interpreter actions/setup-python installs — that matters for anything mypy resolves at import time, such as which typeshed stubs ship. The --python-version flag tells mypy which target language level to analyze against, independent of the interpreter actually running the tool. Set both to the same value on each leg and the analysis is honest end to end.

# .github/workflows/typecheck.yml — GitHub Actions, mypy 1.13
name: typecheck
on:
  pull_request:
  push:
    branches: [main]

jobs:
  mypy:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false                       # every version reports independently
      matrix:
        python-version: ["3.9", "3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - run: pip install -e ".[dev]"          # mypy pinned in pyproject
      - name: Run mypy for this target
        run: >
          mypy
          --python-version ${{ matrix.python-version }}
          --show-error-codes
          src/

fail-fast: false is the load-bearing line. The default (true) cancels every sibling leg the instant one fails, so a [syntax] error on 3.9 would hide whatever 3.13 was about to report. You want the full picture: is a failure universal, or specific to one release?

What a single run misses

Consider the modern union operator. Written as a runtime type it only evaluates on 3.10+, but as an annotation under from __future__ import annotations it is a string mypy still validates against the target.

# src/config.py — checked with mypy 1.13
from __future__ import annotations

def build_dsn(port: int | None) -> str:   # ok on --python-version 3.12
    ...
# src/registry.py — mypy 1.13, evaluated at runtime (no __future__)
IntOrStr = int | str                        # runtime: TypeError on 3.9
# mypy --python-version 3.9 flags: unsupported operand [operator]

PEP 695 is the sharper case. The type statement and class Repo[T] syntax are hard syntax errors before 3.12, and only a matrix that includes an old target catches it.

# src/repo.py — PEP 695 generics, mypy 1.13
type OrderId = int                          # 3.12+ only
class OrderRepository[T]:                    # 3.12+ only
    def get(self, key: OrderId) -> T: ...
# mypy --python-version 3.11 flags: [syntax]  "type parameter syntax requires 3.12"

The new-union rewrite is exactly the kind of change ruff’s UP007 / UP045 rules will suggest; the matrix is what proves the rewrite is safe on your lowest supported version before you apply it. PEP 695 details live in the PEP 695 type parameter syntax guide, and the union forms in Union and Optional types.

Runtime vs static analysis mypy --python-version 3.9 changes what the checker analyzes, not what Python runs. mypy always executes under the interpreter that installed it — the flag only swaps the target language level and stub set. To catch an error that is genuinely a runtime TypeError (like int | str on 3.9), you still need the matching interpreter in setup-python, which is why the matrix pins both.

Stub differences across versions

typeshed ships version-conditional stubs. A stdlib function whose signature changed — say a parameter that became keyword-only, or a return type narrowed from Any — is annotated differently per target. mypy reads the stub branch for your --python-version, so a call that is clean on 3.12 can raise [call-overload] or [arg-type] on 3.9 where the older signature applies. Matrix legs are the only way to see both.

Common mistakes

  • Omitting --python-version entirely. Every leg then analyzes against mypy’s default target and the matrix tests nothing but which interpreter installed the tool. You get four identical result sets.
  • Leaving fail-fast: true (the default). The first red leg cancels the rest, hiding whether a [syntax] or [operator] error is version-specific. Always set it to false for a type matrix.
  • Testing only the newest version. Modern syntax passes, and the [syntax] errors that only fire on your oldest supported release never appear until a user on 3.9 files a bug.
  • Interpreter and target drifting apart. Installing 3.12 but passing --python-version 3.9 checks 3.9 semantics against 3.12 stubs — a mismatch that can mask [attr-defined] errors. Keep the two values equal per leg.

FAQ

Do I need both setup-python and --python-version? Yes, for full fidelity. --python-version alone changes the analysis target but leaves you on whatever interpreter is installed, so a genuine runtime TypeError on an old release stays invisible. Pinning setup-python to the same value makes each leg both analyze and (via your test job) run under that version.

Can I list versions once and reuse them for tests and mypy? Yes. Define the matrix at the workflow level and reference ${{ matrix.python-version }} in both your pytest and mypy jobs, or use a shared include list, so the type matrix and test matrix never drift out of sync.

Back to GitHub Actions Type Checking