Confession: I have never once tested a project against a Python release candidate. Not 3.12, not 3.13, not 3.14. I read the “what’s new” page in October like everyone else, bump the CI matrix in November, and fix whatever breaks in December while pretending I’m surprised. So when the final Python 3.15.0 release candidate landed this week with a five-week window before the October release, I decided to break the habit and actually run my test suites against it. This post is what came out of that, including the three features I’ll use on day one, the removals that bit me, and the nine-line CI change that means I never have to remember to do this again.
Why the RC window matters more than the release
The release announcement is blunt about what an RC is for: from here on, only reviewed bug fixes get in, and third-party maintainers are asked to publish 3.15 wheels now so everyone else can test. Wheels built against the RC will work with the final release. In other words, the ABI is frozen and the only thing left to find is bugs, ideally yours before they become everyone’s.
Simon Willison wrote a short note on the RC2 announcement where he mentions finding a real Python 3.10 bug back in 2021 by running his own test suites against it, except he did it after the release, so the bug had already shipped. That story is the whole argument. The window exists so that people with weird codebases (which is all of us) can trip over things while there’s still time to fix them upstream. If nobody runs the RC, the RC is theatre.
My own excuse was always “my code is boring, nothing will break.” It took about ten minutes to disprove that, which I’ll get to.
The nine lines that make this automatic
The part I’m most pleased with is that I don’t need to remember any of this next year. Simon’s post has the GitHub Actions snippet, and I’ve adopted it wholesale because it’s exactly right:
strategy:
matrix:
python-version: ["3.14", "3.15"]
steps:
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
check-latest: true
allow-prereleases: true is what lets 3.15 resolve to an RC at all. check-latest: true means it picks up the newest available build each run, so it tracked RC1, will move to RC2 once actions/python-versions publishes it, and will quietly switch to the stable release in October without me editing anything. The matrix entry for 3.15 is a permanent fixture now; next year I’ll add "3.16" in the alpha stage and let it fail as much as it wants until the RC.
One thing I got wrong on the first attempt: I put the 3.15 job in the required-checks list. Don’t. During the RC period you want it visible and red, not blocking merges, because sometimes the failure is a dependency that hasn’t shipped a wheel yet and there’s nothing you can do about it. Simon’s own LLM project was blocked waiting on a scikit-learn wheel at the time of writing. Mark the job continue-on-error: true until the final release, then flip it.
The three features I’ll actually use
The what’s new in Python 3.15 page is long, and a lot of it is C API and free-threading work that matters enormously to people who aren’t me. Here’s what changed how I write code.
Lazy imports (PEP 810). This is the headline, and for once the headline earned it. Every CLI I’ve ever written has the same shape: twenty imports at the top, and any given invocation uses four of them. The old workaround was to bury imports inside functions, which works and which every linter and every reviewer hates.
# Before: the import-inside-function dance to keep startup fast
def export_pdf(report):
import reportlab # slow, only needed here
...
def export_xlsx(report):
import openpyxl # also slow, also only needed here
...
# Python 3.15: declare it at the top, pay for it on first use
lazy import reportlab
lazy import openpyxl
def export_pdf(report):
... # reportlab loads here, the first time this runs
def export_xlsx(report):
...
The docs are clear about the restrictions, and they’re sensible: lazy only works at module scope, not inside functions or try blocks, and you can’t lazy a star import. If the module turns out not to exist, the error fires at first use, and the traceback shows both the use site and the original import line, which is the detail that makes this debuggable. There’s also a -X lazy_imports=all flag and a sys.set_lazy_imports_filter() hook if you want to make your own package lazy while keeping third-party imports eager. For code that has to keep supporting 3.14, you can list module names in a __lazy_modules__ variable and plain import statements for those become lazy on 3.15 and stay normal elsewhere.
I’ve been sceptical of lazy-import proposals before because they change when side effects happen, and some libraries do real work at import time. That risk hasn’t gone away. It’s just now explicit in the source instead of hidden in a function body, which I’ll take.
Unpacking in comprehensions (PEP 798). Small, and I’ll use it weekly.
# Before
from itertools import chain
flat = list(chain.from_iterable(batches))
merged = {k: v for d in configs for k, v in d.items()}
# 3.15
flat = [*batch for batch in batches]
merged = {**d for d in configs}
I’ve written the nested-for version so many times that I’d stopped noticing it was a workaround. Now I notice.
frozendict (PEP 814). A real immutable, hashable mapping in builtins. Not a dict subclass, so isinstance(x, dict) is False, which will surprise some code. It’s hashable when its contents are, it keeps insertion order, and two frozendicts with the same items compare equal regardless of order. I’ve used types.MappingProxyType as a stand-in for years and always felt slightly dirty about it; a proxy over a dict someone else can still mutate isn’t immutability, it’s a promise. This is the thing I wanted.
What broke, and what I’d check first
Here’s where “my code is boring” fell apart. Two of three projects failed on 3.15, neither for interesting reasons.
The first was datetime.strptime with a %d in the format string and no year. That’s been a DeprecationWarning since 3.13 and is now a ValueError. I had a helper parsing “DD Mon” strings from a supplier’s CSV and had been ignoring the warning, because it was a warning. Fix was one line, adding a default year. Lesson was bigger: I’d suppressed deprecation warnings in the test config years ago and never turned them back on.
The second was a test that constructed ast nodes by hand with a missing required field. Also deprecated since 3.13, also now a TypeError. Also my fault.
The Removed section of the changelog is where I’d point anyone before running the RC. Everything there was deprecated in 3.12 or 3.13, so if you’ve been running with -W error::DeprecationWarning you’re clean already. If you haven’t, do that first, on your current Python, and you’ll see most of your 3.15 failures before installing 3.15. It’s the cheapest form of the test.
None of my failures involved lazy imports, the new profiler, or anything shiny. They were three-year-old warnings I’d been stepping over. That matches my experience in the Python 2 to 3 migration post: the upgrade cost is almost never the new features, it’s the debt you already had.
The stuff I’m not going to pretend to have opinions on yet
The 3.15 notes also cover a significantly upgraded JIT, frame pointers on by default for system-level observability (PEP 831), a new profiling package with a sampling profiler called Tachyon, and a stable ABI for free-threaded builds. I ran my suites; I did not benchmark them. Anyone telling you what the 3.15 JIT does to your workload three days after RC2 is guessing, and the honest answer for most web and CLI code is that you won’t notice either way. I’ll come back to the profiler once I’ve used it on something real. If you want a version of this post that skips the honesty, there are plenty of those.
Do this before October
Add the nine-line matrix entry above to one repo today, with continue-on-error: true. Run your current Python with -W error::DeprecationWarning locally and fix what surfaces, because that’s most of what 3.15 will throw at you. Then pick one slow-starting script and try lazy import on its heaviest dependency; time it with python -X importtime before and after so you’re looking at a number rather than a feeling.
I keep notes on upgrades like this, and on the CI setups that make them boring, over at abrarqasim.com. Being boring about upgrades is the goal.