Module 4: The Matrix Versions And Environments
4. The operating-system matrix
Description
The previous lesson gave you one dimension: several Python versions. This one gives you the second: several operating systems. And with it comes the idea that makes the matrix more than a list: when you declare two dimensions, GitHub doesn't add them, it multiplies them. Three versions and three operating systems aren't six jobs, they're nine —the Cartesian product, each version tested on each system—. Understanding that multiplication is understanding why a matrix can become huge very fast, and why lesson 7 (when it pays off) exists.
By the end you'll be able to write os: [ubuntu-latest, macos-latest, windows-latest] alongside the version list, understand that runs-on: ${{ matrix.os }} sends each cell to its system, and —most importantly— know which real differences between operating systems justify turning on this second dimension. You're going to see, with values measured for real on macOS, how the path separator, the line ending, the system name, and the encoding change, and you're going to run a Reservo test designed to survive those differences —and understand why one written with less care would break only on Windows—.
Connection to the module: lesson 3 built the version dimension; this one builds the operating-system one and, by joining them, teaches you the multiplication that governs the size of every matrix. Lesson 5 will use this 3×3 grid as the canvas on which include/exclude trim and add cells. Lesson 6 will read the nine results this matrix produces. And lesson 7 will look at this 3×3 = 9 and ask "do you really need all nine?". So nail down two things here: the syntax of the second dimension, and the concrete catalog of what changes between systems.
The blueprint that looks the same on two different sites
An architect hands the same blueprint to two construction teams, one on the coast and one in the mountains. The blueprint is identical: same measurements, same doors. But the coast house is built on sand and the mountain one on rock; on the coast you have to seal against salt humidity and in the mountains you have to insulate against the cold. The same blueprint, executed on two different terrains, produces two houses that —if the architect didn't foresee the terrain— can have opposite problems: one with leaks, another with cracks from the ice.
Your Python code is the blueprint. The operating system is the terrain. The blueprint looks the same —the same open(), the same os.path.join, the same Reservo logic—, but the terrain underneath changes: on Linux the path separator is /, on Windows it's \; on Linux a text line ends in one character, on Windows in two. A well-made blueprint takes the terrain into account (uses os.path.join instead of gluing / by hand) and builds well on all three. A careless blueprint assumes its own terrain and cracks on the other.
The operating-system matrix is sending the blueprint to all three terrains before approving it: building the house on sand, on rock, and in the city, and verifying all three stand. If one cracks, you discover it in the model, not when the customer already lives inside.
The YAML: two dimensions that multiply
Here's the Reservo workflow with the two dimensions. It's lesson 3's, with two changes: an os list in the matrix, and runs-on reading that list instead of being fixed at ubuntu-latest.
# .github/workflows/tests.yml
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }} # <- no longer fixed: the cell chooses it
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest] # dimension 1
python-version: ["3.11", "3.12", "3.13"] # dimension 2
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- 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
Two new pieces:
os: [ubuntu-latest, macos-latest, windows-latest] — a second matrix dimension, with its own list. The values are the names of the runners GitHub offers: ubuntu-latest (Linux), macos-latest (macOS), windows-latest (Windows). The name os you choose, just like python-version.
runs-on: ${{ matrix.os }} — before, runs-on said ubuntu-latest fixed; now it reads the cell's value. In the Windows cell it resolves to runs-on: windows-latest and the job runs on a Windows machine; in the macOS one, on a Mac. So the os dimension doesn't just appear in a name: it changes the machine where the job runs. This is the difference from the version dimension, which changed the installed Python; the os one changes the whole system underneath.
And now the multiplication. With two dimensions, GitHub generates one job per combination of one value from each list. Three systems × three versions = nine jobs:
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)
A grid. Each cell is your complete suite running in that exact combination of system and version. You wrote two short lists —three and three— and got nine runs. This is the rule that governs the size of every matrix:
With several dimensions, the number of jobs is the PRODUCT of the list sizes, not the sum. 3 × 3 = 9, not 6. Adding a value to a list of three, when you have another list of three, doesn't add one job: it adds three.
Internalizing that multiplication is half of knowing how to design matrices. The other half is knowing which of those nine cells you really need —lesson 7—.
What really changes between systems, measured
Lesson 2 named the differences between systems; here we measure them on the real machine (macOS, which is a POSIX system like Linux) so they stop being abstract. Let's run a python -c that prints the values that change by system:
python -c "import os, sys; print('sys.platform =', sys.platform); print('os.name =', os.name); print('os.sep =', repr(os.sep)); print('os.linesep =', repr(os.linesep))"
What to expect. On macOS with Python 3.14.0, measured for real:
sys.platform = darwin
os.name = posix
os.sep = '/'
os.linesep = '\n'
Read it value by value, and alongside what Windows would say (which we can't run here, but whose values are known and documented):
| Value | macOS / Linux (measured) | Windows |
|---|---|---|
sys.platform | darwin (macOS), linux (Linux) | win32 |
os.name | posix | nt |
os.sep (path separator) | / | \ |
os.linesep (line ending) | \n | \r\n |
Four differences, four sources of only-on-one-system bugs:
os.sepis the most common. If your code builds a path with"reports" + "/" + file, on macOS it givesreports/file(fine) and on Windows it should bereports\file, but your hand-written/doesn't respect that. A test that compares the path against a string written with/will pass on macOS and fail on Windows.os.linesepbites when writing or comparing text. A file written "with the system's line endings" has\non macOS and\r\non Windows; anassert content == "a\nb\n"can fail on Windows because of the extra\r.sys.platform/os.nameare the ones you use to decide behavior by system, just likesys.version_infofor the version. Askipif(sys.platform == "win32", ...)skips a test on Windows.
The demo: a test that survives all three systems
Reservo generates reports, and a report is saved to a path. Here's the test for that path, written carefully so it doesn't break over the separator. We keep it as a separate demonstration file apart from the suite's core —in demo_os/—, because it tests an operating-system concern, not the business logic:
# demo_os/test_os_features.py
import os
import sys
import pytest
def test_report_path_uses_the_os_separator(tmp_path):
# Building the path with os.path.join uses the system separator:
# '/' on Linux/macOS, '\\' on Windows. The test holds on any OS
# because it compares against os.sep, not against a hand-written separator.
path = os.path.join("reports", "2026-08", "daily.txt")
assert path == os.sep.join(["reports", "2026-08", "daily.txt"])
@pytest.mark.skipif(
sys.platform == "win32",
reason="on POSIX (Linux/macOS) the separator is '/'; on Windows it would be '\\'",
)
def test_posix_separator_is_forward_slash():
assert os.sep == "/"
The first test is the model to imitate: it builds the path with os.path.join (which uses the correct system separator) and compares it against os.sep.join(...) (which also uses the system separator). Since both sides use os.sep, the test is true on any system: on macOS both sides give reports/2026-08/daily.txt, on Windows both would give reports\2026-08\daily.txt. It doesn't assume a separator; it adapts.
The second test uses skipif with sys.platform —the "per operating system" version of the per-version skipif you already know—. It only makes sense on POSIX (where the separator is /), so it skips on Windows. Let's run them for real:
Worked example
python -m pytest -v -rs demo_os/test_os_features.py
What to expect. On macOS with Python 3.14.0, measured by 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
collecting ... collected 2 items
demo_os/test_os_features.py::test_report_path_uses_the_os_separator PASSED [ 50%]
demo_os/test_os_features.py::test_posix_separator_is_forward_slash PASSED [100%]
============================== 2 passed in 0.01s ===============================
Both pass on macOS. Notice that the second, test_posix_separator_is_forward_slash, didn't skip: its skip condition is sys.platform == "win32", and here sys.platform is darwin, so the condition is false and the test runs (and passes, because os.sep == "/" on macOS). In the Windows cell, that same test would skip —sys.platform would be win32, the condition would be true— and that cell's summary would say 1 passed, 1 skipped. Again the module's pattern: the same suite, a different detail per cell, each one testing what applies to its terrain.
Now the contrast that justifies the matrix. Imagine someone had written the first test without care, like this:
def test_report_path_bad():
path = os.path.join("reports", "2026-08", "daily.txt")
assert path == "reports/2026-08/daily.txt" # <- separator '/' written by hand
On macOS it passes (os.path.join produces /), and whoever wrote it publishes happily. In the matrix's Windows cell, os.path.join produces reports\2026-08\daily.txt, which is not equal to reports/2026-08/daily.txt, and the test gives red —only on Windows—. That red is the whole value of the os dimension: without it, the bug would travel to any Windows user without anyone knowing. With it, the test (windows-latest, 3.12) cell turns red, tells you exactly where, and you fix it before publishing.
When the OS dimension matters (and when it doesn't)
A preview of lesson 7, because it applies particularly to the operating system. The os dimension pays off when your code touches the file system, writes or reads text with line endings, handles paths, or depends on libraries with per-system compiled parts. There the terrain differences are real and only an OS matrix catches them.
But not all code touches the terrain. Reservo's pure logic —price_cents, refund_cents, overlaps, integer arithmetic and date comparisons— gives exactly the same on Linux, macOS, and Windows: there are no paths, no files, no line endings. For that part, running the three-system matrix is spending three times the minutes to get three times the same green. The honest question isn't "can I test on three systems?" but "does my code do something different on three systems?". If the answer is no, the os dimension is noise. Reservo, as it stands, hardly needs it —and that honesty is exactly what lesson 7 teaches you to defend—.
Common mistakes
Believing two dimensions add up. What happens: someone puts three systems and three versions expecting "about six jobs" and is surprised with nine runs and triple the billed minutes. Why it happens: intuitively "3 and 3" sounds like 6; but the matrix does the Cartesian product, each version on each system. How to spot it: multiply the sizes of all your lists before pushing; that product is your number of cells. How to fix it: remember the rule —product, not sum— and if the number is scary, trim with exclude (lesson 5) or reduce a list based on what you really need (lesson 7).
Gluing paths with / and testing only on Mac or Linux. What happens: you build paths with folder + "/" + file or compare against strings with /, it works on your POSIX, and the Windows cell turns red (or worse, you don't have that cell and the bug reaches the user). Why it happens: on your system / is the separator, so the error is invisible to you. How to spot it: search for literal "/" and "\\" in code that builds or compares paths. How to fix it: os.path.join or pathlib.Path to build, and compare against os.sep.join(...) or using pathlib, never against a hand-written separator.
Turning on the OS dimension for code that doesn't touch the system. What happens: you add os: [ubuntu, macos, windows] to a pure-logic suite like Reservo's, tripling the cells to test arithmetic that's identical on all three systems. Why it happens: "more systems feels more robust". How to spot it: ask yourself for each test "could this result change depending on the operating system?". If it's arithmetic, comparisons, or pure logic, the answer is no. How to fix it: reserve the os dimension for code that really touches paths, files, text, or compiled binaries; for the rest, a single system row is enough. Lesson 7 gives you the complete criterion.
Exercises
Exercise 1 — Count the cells. For each matrix, say how many jobs it generates and write the name of two of them: (a) os: [ubuntu-latest, windows-latest] and python-version: ["3.11", "3.12", "3.13", "3.14"]; (b) only os: [ubuntu-latest, macos-latest, windows-latest], with no version dimension; (c) os: [ubuntu-latest] and python-version: ["3.11", "3.12"].
See solution
- (a) 2 × 4 = 8 jobs. The product of two systems by four versions. Two example names:
test (ubuntu-latest, 3.11)andtest (windows-latest, 3.14). - (b) 3 jobs. A single dimension of three values; with no version dimension, there's nothing to multiply. Names:
test (ubuntu-latest),test (macos-latest)(andtest (windows-latest)). - (c) 1 × 2 = 2 jobs. One system by two versions. Names:
test (ubuntu-latest, 3.11)andtest (ubuntu-latest, 3.12).
The recurring rule: multiply the sizes of all the lists. A single list isn't multiplied by anything, so its number of jobs is its own size. This calculation is the one you must do before pushing, because it's the number of runs —and of minutes— per change.
Exercise 2 — Fix the test that only fails on Windows. This test passes on the machine of whoever wrote it (a Mac) and the matrix's Windows cell turns it red. Explain why it fails on Windows and rewrite it so it passes on all three systems.
def test_report_dir():
base = "reports"
sub = "2026-08"
full = base + "/" + sub
assert full == os.path.join(base, sub)
See solution
It fails on Windows because the left side, base + "/" + sub, builds the path with a hand-written /, always giving reports/2026-08. But the right side, os.path.join(base, sub), uses the system separator: on Windows it produces reports\2026-08. So reports/2026-08 == reports\2026-08 is false on Windows, and the test gives red. On macOS it passes by coincidence, because there the separator is also / and both sides match.
The fix is to not write the separator by hand; build the path with the same tool that respects the system:
def test_report_dir():
base = "reports"
sub = "2026-08"
full = os.path.join(base, sub) # uses the system separator
assert full == os.sep.join([base, sub]) # also, to compare without assuming
Now both sides use os.sep, so the test is true on macOS (reports/2026-08) and on Windows (reports\2026-08) alike. The lesson: never write / or \ by hand in a path you're going to compare; let os.path.join/pathlib put the terrain's separator.
Exercise 3 — Does Reservo deserve the OS dimension? Look at Reservo's two parts: (i) the pure logic —price_cents, refund_cents, overlaps, book—, which is integer arithmetic and date comparisons; (ii) the new report_pages and writing reports to disk. Decide, for each, whether it's worth testing on [ubuntu, macos, windows] or whether a single system row is enough, and justify.
See solution
- (i) The pure logic → a single system row is enough.
price_cents,refund_cents,overlaps, andbookare integer arithmetic anddatetimecomparisons.2500 * 3gives7500identical on Linux, macOS, and Windows; there are no paths, files, line endings, or compiled binaries involved. Testing it on three systems would give three times the same green, spending triple the minutes without catching anything. For this part, theosdimension is noise; one row (for exampleubuntu-latest) is enough. - (ii) Writing reports to disk → the OS matrix does pay off. As soon as Reservo writes a file (paths, line endings, encoding), the terrain matters: the
/vs\separator, the\nvs\r\n, the default encoding. There a careless test breaks only on Windows, and only a Windows cell catches it before the user. For this part,[ubuntu, macos, windows]pays off.
The honest conclusion: Reservo, while it's pure logic, hardly needs the OS dimension; as soon as it touches the disk, it does. The matrix isn't turned on by reflex, it's turned on where the code really touches the terrain. This reasoning —turn on only the cells that catch something real— is the heart of lesson 7.
Summary and next step
In this lesson you added the second matrix dimension —the operating system— with os: [ubuntu-latest, macos-latest, windows-latest] and runs-on: ${{ matrix.os }}, which sends each cell to its machine. And you learned the rule that governs the size of every matrix: with several dimensions, the number of jobs is the product of the list sizes, not the sum —3 × 3 = 9, a grid—.
You measured for real what changes between systems: on macOS, sys.platform = darwin, os.name = posix, os.sep = '/', os.linesep = '\n', against Windows's values (win32, nt, \, \r\n). You saw a carefully written test —with os.path.join and os.sep— pass on all three systems, and understood how a careless one, with / by hand, would pass on your Mac and turn red only on Windows: exactly the bug the OS dimension exists to catch. And the underlying honesty became clear: Reservo's pure logic doesn't change by system, so that dimension pays off only when the code touches paths, files, or text.
Before moving on you should be able to: write a two-dimension matrix; calculate how many cells it generates (product, not sum); name four things that change between operating systems and how Python measures them; and decide whether a piece of code deserves the OS dimension.
What's next, in lesson 5, is fine-tuning this grid without writing each cell by hand: include to add special cells, exclude to remove the ones that don't apply, and fail-fast to decide whether the matrix stops at the first red or runs complete to show you the whole map. With nine cells on the table, it starts to matter which you remove, which you add, and what happens when one fails.
Resources
- Choosing the runner for a job — GitHub Actions — the list of available runners (
ubuntu-latest,macos-latest,windows-latest) and which system each is. It's the reference for the values that go in theoslist. os.sepandos.linesep— Python documentation — the values we measured, officially defined. Read them alongsideos.path.join: understanding that the separator depends on the system is the key to writing code that survives the OS matrix.sys.platform— Python documentation — the system identifier (linux,darwin,win32) used in per-operating-systemskipif, the counterpart of per-versionsys.version_info.- Using a matrix for your jobs: example matrices — GitHub Actions — examples of multidimensional matrices with
osandpython-version, the exact pattern of this lesson. The "expanding configurations" section sets up the ground for lesson 5'sinclude/exclude.