Quick war story. Last week I pushed an update to shiftfill that included a seemingly innocuous line:
message = f"Shift swap approved: {shift.day}\n{shift.start}-{shift.end}"
On my laptop, Python 3.12, it ran fine. Tests passed locally. I pushed.
About 90 seconds later, the GitHub Action failed on the 3.10 and 3.11 jobs with:
SyntaxError: f-string expression part cannot include a backslash
3.12 passed. 3.10 and 3.11 died.
What Changed in Python 3.12
Python 3.12 relaxed a bunch of restrictions on f-string parsing as part of PEP 701. One of them was the ban on backslashes inside f-string expressions. Earlier Python versions would hard-fail at parse time if you had any \ inside the {} portion of an f-string. You had to factor the backslash into a variable first:
# Works in 3.10, 3.11, 3.12:
nl = "\n"
message = f"Shift swap approved: {shift.day}{nl}{shift.start}-{shift.end}"
# Works ONLY in 3.12+:
message = f"Shift swap approved: {shift.day}\n{shift.start}-{shift.end}"
I'd written the 3.12 version because that's the version on my laptop, and the code looked fine. 3.12 is permissive. 3.11 is not.
Why This Matters for Self-Host
If I'd merged this and shipped it, everyone self-hosting shiftfill on Ubuntu 22.04 (Python 3.10 by default) would have seen their app fail to start with a SyntaxError. The error message is clear enough to decode, but the user experience would have been: "your update broke my install." That's the kind of thing that destroys trust in a self-host user base really fast.
The user base for self-host is important to us. I wrote about why every one of our apps has a self-host tier, and the short version is: it's the trust mechanism that makes the whole business work. If we ship code that only runs on the newest Python, we're saying "trust us, but only if you update to the latest OS." That's not trust; that's a threat.
What the CI Matrix Looks Like
The template's GitHub Action looks roughly like:
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -r requirements.txt
- run: pytest -q
Every push runs three parallel jobs, one per Python version. All three have to pass before the branch can merge. In this case, 3.12 passed and 3.10/3.11 failed, which is exactly the signal I wanted.
I added 3.13 to the matrix last month when it stabilized. I'll probably drop 3.10 later this year when 24.04 becomes the default on more self-host targets. The matrix moves slowly, deliberately.
The Counter-Argument and Why I Ignore It
Someone will say: "just require Python 3.12 in your setup.py, problem solved." And that's a reasonable position for a lot of projects. But it doesn't fit us.
Ubuntu 22.04 LTS ships with Python 3.10. Ubuntu 24.04 LTS ships with 3.12. Both are supported by Canonical until 2029 and 2034 respectively. A self-host user on 22.04 — which is a lot of people, because 22.04 is still the most common LTS in the wild — would have to install a newer Python from a PPA or deadsnakes to run our apps. That's a papercut that makes self-host harder, which erodes the whole reason we offer self-host.
Staying compatible with 3.10 for now costs us: a couple of minutes per week editing around the relaxed 3.12 features. Dropping 3.10 support would cost us: a percentage of our self-host user base getting frustrated and leaving. The math works out toward "keep the matrix."
Every test-matrix job you skip is a bug that ships to someone whose setup you didn't test. The matrix is how we extend the promise of 'this code works' to environments we don't personally use.
The Fix, For Completeness
The fix was one line:
# Before (works in 3.12 only):
message = f"Shift swap approved: {shift.day}\n{shift.start}-{shift.end}"
# After (works everywhere):
nl = "\n"
message = f"Shift swap approved: {shift.day}{nl}{shift.start}-{shift.end}"
Not elegant. Works. Shipped.
I could have used chr(10) or a multi-line f-string or separated the formatting entirely. The variable extraction is what I'd reach for reflexively at this point, because I've hit this same restriction on a half-dozen projects over the years.
What This Says About Our Setup
Three things, I think.
One, the CI matrix is worth the 90 seconds it adds to every push. This bug would have been a customer-facing outage. Instead, it was a red X on a pull request that I fixed in four minutes. That trade favors the matrix every time.
Two, the template paying attention to Python-version compatibility across all 220+ verticals means I don't have to think about this per-vertical. The shiftfill repo inherited the matrix from the template. When I update the matrix in the template, every vertical picks up the new version constraints on next push. One place to manage the policy, every vertical gets the enforcement.
Three, these bugs are small but the category of bug is not small. Every Python release adds and removes syntax in small ways. Every one of those changes has the shape of "works on my machine, breaks somewhere else." You can't catch these through code review alone because the language is the thing that's changed. You need actual execution on actual versions. That's what CI is.
If you're shipping Python code that other people will run in environments you don't control, run your tests on multiple versions. It's cheap. It's slow to set up once and then invisible forever. And the first time it catches something like this, you'll be glad it was there.