Module 4: The Matrix Versions And Environments
3. `strategy.matrix` of Python versions
Description
You already know why you'd want to run the Reservo suite on several Python versions: because your code can touch a difference between 3.11 and 3.13 without noticing, and a single green job would hide it. This lesson teaches you how that's written in the GitHub Actions YAML, and it's simpler than it seems: a few lines turn one job into three. The piece is called strategy.matrix, and it's the mechanical heart of the whole module.
By the end you'll be able to write a workflow that declares a list of Python versions —["3.11", "3.12", "3.13"]— and understand exactly what GitHub does with it: expand a single job into three identical jobs, each the same except for the version it installs. You're going to see how each cell's version is injected into the setup-python step with the syntax ${{ matrix.python-version }}, how the CI log would look with its three job entries, and —this is what anchors everything— you're going to run the suite locally on Python 3.14 as the witness cell: one of the runs the matrix would do, executed for real so you see its real output. The YAML is honest content (there's no runner here); pytest is real.
Connection to the module: lesson 1 gave you the matrix concept and lesson 2 the catalog of differences that justify it. This is the first of the "construction" lessons: you write the version dimension. Lesson 4 adds the operating-system dimension and you'll see how two lists multiply into a grid. Lesson 5 fine-tunes it with include/exclude/fail-fast. Lesson 6 teaches you to read the N results this matrix produces. So nail down the mechanics here —list → N jobs, ${{ matrix.x }} injects the value— because everything else reuses it.
A recipe with one ingredient that changes
Imagine you write a bread recipe and want to publish it tested with three types of flour: wheat, whole-grain, and rye. You could write the recipe three times, copying each step —knead, rest, bake— and changing only the flour line. Three nearly identical recipes, and if tomorrow you change the baking time, you have to correct it in all three and pray you don't forget one.
Or you can write the recipe once, with a blank where the flour goes, and a note at the top: "prepare this recipe with each of these flours: wheat, whole-grain, rye". A diligent reader executes it three times, filling the blank with each flour. A single recipe, a single place to correct the baking, and three loaves tested.
strategy.matrix is exactly that second form. You write the job once —checkout, install Python, install dependencies, run pytest— with a blank where the version goes, and you declare the list of versions at the top. GitHub Actions is the diligent reader: it takes your single job, runs it once for each version in the list, filling the blank each time. You don't copy the job three times; you write it once and declare the ingredient that changes. If tomorrow you add a step, you add it in a single place and all three cells inherit it.
strategy.matrixtakes a job written a single time and a list of values, and generates one job per value. The list of versions is the "ingredient that changes"; the rest of the job is the shared recipe.
The YAML, piece by piece
Here's the complete workflow that runs the Reservo suite on three Python versions. Read it whole first; below we take it apart line by line. (Remember: this is content —this is how the real workflow looks—; no runner executes it here, but it's exactly what you'd put in your repo.)
# .github/workflows/tests.yml
name: tests
on: [push, pull_request] # the workflow runs on every push and every PR
jobs:
test:
runs-on: ubuntu-latest # all cells run on Linux (for now)
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"] # <- the list that expands
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }} # <- the blank that gets filled
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run the test suite
run: python -m pytest -v
Now the pieces that matter for the matrix. The rest —on, checkout, install dependencies, run pytest— is the workflow anatomy you saw in module 2; here it doesn't change.
strategy: — opens the job's strategy block. Everything that has to do with "how this job multiplies" lives inside here: the matrix, and later on (lesson 5) fail-fast and max-parallel.
matrix: — declares the matrix proper. Inside you put one or more dimensions, each a named list. Here there's a single dimension.
python-version: ["3.11", "3.12", "3.13"] — this is the dimension, and its name —python-version— you choose (it could be called py or version; the name is yours). The value is a list of three strings. Here the magic happens: GitHub sees a list of three elements and generates three jobs, one with python-version = "3.11", another with "3.12", another with "3.13". Watch out for the quotes: the versions go as strings ("3.11", not 3.11), because YAML would interpret unquoted 3.10 as the number 3.1 —the trailing 0 is lost— and you'd end up installing Python 3.1, which doesn't exist. Quotes always.
${{ matrix.python-version }} — this is the syntax for reading the current cell's value. Inside each expanded job, matrix.python-version is that cell's version. It appears in two places: in the step's name (so the log says "Set up Python 3.12" and you know which cell it is) and —the one that does the work— in setup-python's python-version:, where it tells the action exactly which Python to install. In the 3.11 cell that line resolves to python-version: 3.11; in the 3.13 one, to python-version: 3.13. The same YAML, three different resolutions.
It's worth seeing it "expanded" mentally. GitHub takes your single test job and turns it, internally, into something equivalent to this:
test (3.11) -> runs-on ubuntu-latest, installs Python 3.11, runs pytest
test (3.12) -> runs-on ubuntu-latest, installs Python 3.12, runs pytest
test (3.13) -> runs-on ubuntu-latest, installs Python 3.13, runs pytest
Three jobs, identical except for the version, running in parallel (each on its own runner). You wrote the job once; GitHub tripled it.
How the CI log looks
When this runs on GitHub, you don't see one log, you see three, one per cell. In the run's tab the list of jobs appears, each with its name in parentheses and its traffic light. This is how it would look (this is the honest format of the log; not a screenshot of a runner that doesn't exist here):
tests · push to main
✓ test (3.11) — 8 passed, 1 skipped in 0.4s
✓ test (3.12) — 8 passed, 1 skipped in 0.3s
✓ test (3.13) — 8 passed, 1 skipped in 0.3s
Notice how GitHub names each job: test (the job name) plus, in parentheses, the cell's value: test (3.11), test (3.12), test (3.13). That name is your map: if tomorrow a cell turns red, the name tells you at once which version —test (3.11) in red means "it broke on 3.11 and only there"—. Lesson 6 is dedicated entirely to reading this; for now keep in mind that each cell is a check with its own name.
And look at the skipped detail: each cell says 8 passed, 1 skipped, but —as we saw in lessons 1 and 2— the test that skips is different on each version. In test (3.11) the itertools.batched branch skips; in test (3.12) and test (3.13) the manual fallback skips. The count matches, the detail doesn't, and that's why you run all three: each one exercises a different branch of the code.
The witness cell: run one for real, locally
We don't have a GitHub runner, but we do have something just as good for learning: your machine is a matrix cell. It runs Python 3.14.0, so it plays witness to what the test (3.14) cell would do —or, in spirit, any cell ≥ 3.12, because the code path is the same—. Running the suite locally is really executing what the YAML's Run the test suite step would do in that cell. Let's do it with -v to see each test, just as the YAML asks (python -m pytest -v):
python -m pytest -v tests/
What to expect. On Python 3.14.0 with pytest 9.1.1, measured by really executing:
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- /private/tmp/reservo-m4/.venv/bin/python
cachedir: .pytest_cache
rootdir: /private/tmp/reservo-m4
collected 9 items
tests/test_pricing.py::test_basic_three_hours PASSED [ 11%]
tests/test_pricing.py::test_pro_three_hours PASSED [ 22%]
tests/test_pricing.py::test_basic_one_hour PASSED [ 33%]
tests/test_refunds.py::test_full_refund_72h_before PASSED [ 44%]
tests/test_refunds.py::test_half_refund_36h_before PASSED [ 55%]
tests/test_refunds.py::test_no_refund_12h_before PASSED [ 66%]
tests/test_version_features.py::test_report_pages_groups_bookings PASSED [ 77%]
tests/test_version_features.py::test_report_pages_uses_stdlib_batched PASSED [ 88%]
tests/test_version_features.py::test_report_pages_manual_fallback_on_old_python SKIPPED [100%]
========================= 8 passed, 1 skipped in 0.01s =========================
Read the first data line: platform darwin -- Python 3.14.0. That line is the one that tells you which cell you're standing in. In CI, the test (3.11) cell would start its log with Python 3.11.x, the test (3.12) one with Python 3.12.x, and so on. When you reproduce a failure (module 3) or read a result (lesson 6), that line is the first thing you look at: it tells you whether the log in front of you is from the cell you think.
And the summary —8 passed, 1 skipped— is exactly what the test (3.14) cell would report in the CI log. The local run isn't an approximate simulation: it's the same command (python -m pytest -v) against the same code, with the only difference that the GitHub runner would run it additionally on 3.11, 3.12, and 3.13. That's why we say your machine is the witness cell: it shows you, for real, what one of the matrix's N runs would do.
Choosing which versions to put in the list
A reasonable doubt: why ["3.11", "3.12", "3.13"] and not something else? The list isn't pulled from a hat; it's derived from a question: which versions do you promise to support? That's the whole topic of lesson 7, but here's the minimal criterion so your YAML isn't arbitrary:
- Include your minimum supported version. If your README says "Python 3.11+", then 3.11 has to be in the list, because it's the most prone to a new function you accidentally used being missing. It's the cell that catches lesson 2's
ImportErrors. - Include the newest stable one. To know your code doesn't break with the latest (changed behavior, deprecations). At the time of writing this it would be 3.13 or 3.14.
- Consider the intermediate ones. If you support 3.11 to 3.13, adding 3.12 too costs one more cell and closes the gap. For a library, you put them all; for something smaller, sometimes the minimum and maximum are enough.
What you shouldn't do is put versions you don't support "just in case": each one is a cell that consumes minutes and that, if it turns red, forces you to fix something no one asked you to support. The matrix list is a promise: "I promise this works on these versions". Don't over-promise.
Common mistakes
Writing the versions without quotes. What happens: you put python-version: [3.10, 3.11, 3.12] without quotes. YAML reads 3.10 as the number 3.1 (the trailing zero of a number is lost), so the cell tries to install "Python 3.1", which doesn't exist, and the job fails in setup-python with a strange error. Why it happens: in YAML, an unquoted value that looks like a number is interpreted as a number, and 3.10 == 3.1. How to spot it: if a cell fails installing Python with a "version not found" message and your list has no quotes, this is it. How to fix it: always quotes on the versions: ["3.10", "3.11", "3.12"]. The 0 survives because now it's a string, not a number.
Forgetting to inject ${{ matrix.python-version }} into setup-python. What happens: you declare the three-version matrix, but in the setup-python step you leave python-version: "3.12" fixed (or omit it). The three cells are generated, but all three install the same version, so you're running the same test three times and believing you tested three versions. Why it happens: the matrix generates the cells, but you have to connect the cell's value to the step that uses it; if not, the dimension doesn't reach anywhere. How to spot it: look at the Python 3.x line in the three cells' logs; if they say the same version, you didn't connect the matrix. How to fix it: python-version: ${{ matrix.python-version }} in setup-python, so each cell installs its version.
Putting versions in the matrix that you don't support. What happens: you add 3.9 and 3.10 "to be safe", even though your project only promises 3.11+. Now those cells fail (you use 3.11 features) and someone loses time fixing support for versions no one asked for, or —worse— they silence them with skipif and the matrix becomes noise. Why it happens: "more versions feels more complete". How to spot it: for each matrix version ask yourself "do I promise this in my README?". If not, it's excess. How to fix it: the matrix list = the versions you promise to support, not one more. Lesson 7 formalizes this criterion.
Exercises
Exercise 1 — Expand the matrix by hand. Given this YAML fragment, write the names of the jobs GitHub would generate and which version each would install:
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
See solution
A list of four versions generates four jobs, one per element:
test (3.10) -> installs Python 3.10, runs pytest
test (3.11) -> installs Python 3.11, runs pytest
test (3.12) -> installs Python 3.12, runs pytest
test (3.13) -> installs Python 3.13, runs pytest
Each job's name is the base job name (test) plus the cell's value in parentheses. Each runs in parallel, identical except for the version setup-python installs thanks to ${{ matrix.python-version }}. The mechanical rule: N elements in the list → N jobs. Four versions are four runs of your suite on each push —a datum that will matter when we talk about cost in lesson 7.
Exercise 2 — Hunt the YAML bug. A teammate declares the matrix like this and complains that "the three cells run Python 3.1 and fail". What's the bug and how is it fixed?
strategy:
matrix:
python-version: [3.10, 3.11, 3.12]
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
See solution
The bug is the missing quotes on the version list. In YAML, 3.10 without quotes is interpreted as the number 3.1 (the trailing 0 of a decimal number contributes nothing and is discarded), and the same would happen with any .X0 version. So the cell that was supposed to be 3.10 asks setup-python for version 3.1, which doesn't exist, and fails. (The 3.11 and 3.12 cells survive by coincidence, because 3.11 and 3.12 as numbers don't lose digits —but it's pure luck, and 3.10, 3.20, etc. do break.)
The fix is to put each version as a string:
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
With quotes, "3.10" is the text 3.10 and the 0 survives intact. Golden rule: Python versions in the matrix always go in quotes. The setup-python step was fine; the problem was only the list.
Exercise 3 — From promise to list. A project's README says: "Compatible with Python 3.11, 3.12, and 3.13. Tested on the latest stable version." It doesn't use any 3.13-exclusive feature. Write the strategy.matrix section that corresponds to that promise, and justify in one sentence why you include (or don't) each version.
See solution
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
Justification per version:
- 3.11 — it's the minimum the README promises; it has to be there, because it's the cell most prone to give away the accidental use of a function that only exists since 3.12+ (an
ImportErrorlike lesson 2's). - 3.12 — it's an intermediate promised version; including it costs one cell and closes the gap between the minimum and the maximum, catching behaviors that changed right there.
- 3.13 — it's the newest stable and the explicit promise "tested on the latest stable version"; it catches deprecations and behavior changes of the latest.
3.10 isn't included (the README doesn't promise it) or 3.14 (not yet the stable one at the time of the promise; it would be added when it is). The list is the README's promise, translated to YAML: not one version too many, not one too few.
Summary and next step
In this lesson you wrote the first dimension of a matrix: strategy.matrix with a list of Python versions. You saw that a list of three versions —["3.11", "3.12", "3.13"], always in quotes so the 0 isn't lost— makes GitHub generate three jobs, identical except for the version, running in parallel. The key connection is ${{ matrix.python-version }}: the syntax that carries each cell's value to the setup-python step, so each job installs its Python. Without that injection, the matrix generates cells but all run the same version.
You saw how each job is named in the CI log —test (3.11), test (3.12), test (3.13)—, how that name is your map to locate which version something failed in, and how each cell reports 8 passed, 1 skipped with the detail that the test that skips changes by version. And you really executed the witness cell: the suite on Python 3.14.0, 8 passed, 1 skipped, the same command the Run the test suite step would run in each cell, with the Python 3.14.0 line that anchors you to which cell you're looking at.
Before moving on you should be able to: write a version strategy.matrix from a support promise; explain what a list of N versions generates; say what ${{ matrix.python-version }} does and where it goes; and detect the missing-quotes bug.
What's next, in lesson 4, is adding the second dimension: the operating system. You're going to see os: [ubuntu-latest, macos-latest, windows-latest], how two lists multiply into a grid (3 versions × 3 systems = 9 cells), and which real differences between systems —with values measured on macOS— justify turning on that second dimension.
Resources
- Using a matrix for your jobs — GitHub Actions — the official reference for
strategy.matrix. Read the "Using a single-dimension matrix" section: it's exactly what we wrote here, a single list that expands into N jobs. actions/setup-python— the action that installs each cell's Python version. Its README shows thepython-version: ${{ matrix.python-version }}pattern and explains which version formats it accepts (including why the quoted string is best).- Workflow syntax:
jobs.<job_id>.strategy— GitHub Actions — the formal definition of thestrategyblock, where the matrix,fail-fast(lesson 5), andmax-parallellive. Useful as a reference when you want the exact name of a key. python -m pytest— command-line invocation — pytest — the way of running pytest the workflow'sRun the test suitestep uses. It's worth knowing whypython -m pytest(with the-m) is more robust in CI than calling barepytest.