Upgrading to Python 3.15
Plan and carry out an upgrade to Python 3.15: read the porting notes, turn deprecation warnings into errors on the old version, handle the UTF-8 default (PEP 686), the 3.15 removals and behaviour changes, prepare for 3.16 and 3.17, and test across versions, including the free-threaded build.
What you will learn
- Find what an upgrade will break by reading What's New and the deprecation lists
- Surface deprecation warnings as errors before you switch versions
- Explain what the UTF-8 default changes, who it affects, and how to find affected code
- Fix code that relies on APIs removed in 3.15 or on behaviour that changed, and plan for removals in 3.16 and 3.17
- Test a project on several Python versions, including 3.15t, and decide when to drop old ones
On this page
Python ships a new version every October, and each one removes some things that were deprecated two or more versions earlier. Python 3.15 was released on 9 October 2026, and 3.10 reached end of life on 1 October, so a project still pinned to an old version is collecting upgrade work it will have to do in a hurry later. An upgrade is a small project: find out what will break, fix it while still on the old version, then switch. The examples in this lesson run on 3.15.
Read before you change anything
Three pages tell you nearly everything:
- What’s New in Python 3.15, especially the “Removed”, “Deprecated” and “Porting to Python 3.15” sections. Porting lists the behaviour changes that are not removals.
- Pending removal pages, one per future version. They are your list for the next two upgrades.
- Your dependencies’ changelogs. A dependency with compiled code needs a
cp315wheel (or anabi3one) before you can install it without a compiler; see the wheel tags in building and publishing. Pure-Python packages usually just work, unless they use something that was removed.
Turn warnings into errors first
Deprecated APIs warn for at least two releases before they go. By default most DeprecationWarnings are hidden, except in code run directly as __main__, so the warnings that predict your upgrade problems are often invisible. Make them fatal in your test run while you are still on the old version:
import subprocessimport sys
LEGACY = """import asyncio
async def job(): pass
print("coroutine function?", asyncio.iscoroutinefunction(job))"""
for flags in ([], ["-W", "error::DeprecationWarning"]): result = subprocess.run([sys.executable, *flags, "-c", LEGACY], capture_output=True, text=True) print(" ".join(flags) or "(no flags)", "-> exit", result.returncode) if result.stdout: print(" stdout:", result.stdout.strip()) warning = result.stderr.strip().splitlines()[-1] print(" stderr:", warning.split("Warning: ")[1].split(";")[0])(no flags) -> exit 0 stdout: coroutine function? True stderr: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16-W error::DeprecationWarning -> exit 1 stderr: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16With -W error::DeprecationWarning the program stops at the first deprecated call, which is exactly what you want in CI. In pytest, filterwarnings = ["error"] under [tool.pytest.ini_options] does the same for the whole suite, and you add narrow ignore entries for warnings that come from dependencies you cannot fix yet. python -X dev enables the default warnings and several other runtime checks for local runs.
UTF-8 by default
Until 3.14, open(path) without encoding= used the locale encoding: UTF-8 on most Linux and macOS systems, but often a legacy code page on Windows. So the same program read a file correctly on one machine and produced mojibake or a UnicodeDecodeError on another. PEP 686 ends that: in 3.15, UTF-8 mode is on by default, and text I/O without an explicit encoding uses UTF-8 everywhere. The PEP notes that the change mostly affects Windows users, where data that was previously written in the locale encoding is now read as UTF-8.
import subprocessimport sys
CHILD = """import sys, tempfileprint("utf8_mode =", sys.flags.utf8_mode)with tempfile.TemporaryFile("w+") as f: # no encoding= given f.write("plain text")"""
for flags in ([], ["-X", "utf8=0"], ["-X", "warn_default_encoding"]): result = subprocess.run([sys.executable, *flags, "-c", CHILD], capture_output=True, text=True) print(" ".join(flags) or "(no flags)") print(" ", result.stdout.strip()) if result.stderr: print(" ", result.stderr.strip().splitlines()[-1])(no flags) utf8_mode = 1-X utf8=0 utf8_mode = 0-X warn_default_encoding utf8_mode = 1 <string>:4: EncodingWarning: 'encoding' argument not specifiedThree tools come out of this. -X utf8=0 or PYTHONUTF8=0 turns the old behaviour back on, as a stopgap. -X warn_default_encoding (or PYTHONWARNDEFAULTENCODING=1) makes every call that relies on the default emit an EncodingWarning, which finds the code to fix. And the fix is to pass encoding="utf-8" explicitly, which also behaves identically on older versions; use encoding="locale" (available since 3.10) where you really mean the system’s encoding, such as output for an old Windows program.
What 3.15 removed
Most removals are things that warned for years. The ones most likely to appear in application code:
| Removed in 3.15 | Use instead |
|---|---|
datetime.strptime() with %d but no year: now ValueError |
include a year in the text and the format |
NamedTuple("P", x=int) keyword form |
class P(NamedTuple) or NamedTuple("P", [("x", int)]) |
TypedDict("TD") with no fields |
class TD(TypedDict): pass or TypedDict("TD", {}) |
sre_compile, sre_parse, sre_constants |
re |
http.server.CGIHTTPRequestHandler, --cgi |
a WSGI or ASGI server |
load_module() on import loaders |
exec_module(); see the import system |
PurePath.is_reserved() |
os.path.isreserved() |
glob.glob0(), glob.glob1() |
glob.glob(..., root_dir=...) |
codeobject.co_lnotab |
co_lines() |
The strptime change catches real bugs. Without a year, Python used 1900, which was not a leap year, so "02-29" could never parse:
from datetime import datetime
birthday = "03-14"try: datetime.strptime(birthday, "%m-%d")except ValueError as error: print("ValueError:", str(error).split(".")[0])
# Say which year you mean, and the leap-day problem goes away.print(datetime.strptime(f"2026-{birthday}", "%Y-%m-%d").date())print(datetime.strptime("2028-02-29", "%Y-%m-%d").date())ValueError: Day of month directive '%d' may not be used without a year directive2026-03-142028-02-29Porting to 3.15: behaviour that changed
Some changes remove nothing. The code still runs, but does something different, and no deprecation warning announced it. What’s New lists them under Porting to Python 3.15; these are the ones most likely in application code:
| Change in 3.15 | How to find affected code |
|---|---|
argparse: with a short option and a single-dash long option, as in add_argument('-f', '-file'), dest is now taken from the long one (file, not f) |
search for add_argument with two single-dash names; pass dest= explicitly |
base64.urlsafe_b64decode() no longer requires padding; padded=True restores the check |
search for code that relies on it raising for unpadded input |
unittest: assertWarns() and assertWarnsRegex() no longer swallow warnings that do not match |
new warnings in the test output, or failures under filterwarnings = ["error"] |
sqlite3.connect(): every parameter except database is keyword-only |
a TypeError at the call; name the arguments |
xml.etree.ElementTree.iterparse() emits a ResourceWarning if it opened a file and is not closed |
run tests with -W error::ResourceWarning; call close() or use contextlib.closing() |
The Removed section adds a few that look like behaviour changes too: AST node constructors raise TypeError for a missing or unknown argument (a DeprecationWarning since 3.13), and importlib.resources.files(package=...), zipimporter.load_module() and @typing.no_type_check_decorator are gone. typing.ByteString and collections.abc.ByteString left their modules’ __all__, so a star import no longer provides them, and importing them warns. Four of the changes, run on 3.15:
import argparseimport base64import sqlite3import unittestimport warnings
parser = argparse.ArgumentParser()parser.add_argument("-f", "-file")print("argparse dest:", vars(parser.parse_args(["-f", "a.txt"])))
print("unpadded base64:", base64.urlsafe_b64decode("YWI"))
case = unittest.TestCase()with warnings.catch_warnings(record=True) as leaked: warnings.simplefilter("always", UserWarning) with case.assertWarns(DeprecationWarning): warnings.warn("old API", DeprecationWarning) warnings.warn("unrelated", UserWarning)print("leaked past assertWarns:", [str(w.message) for w in leaked])
try: sqlite3.connect(":memory:", 5.0) # timeout given by positionexcept TypeError as error: print("TypeError:", error)argparse dest: {'file': 'a.txt'}unpadded base64: b'ab'leaked past assertWarns: ['unrelated']TypeError: connect() takes at most 1 positional arguments (2 given)On 3.14 the same script prints {'f': 'a.txt'} and then stops with binascii.Error: Incorrect padding. The assertWarns change matters most for a suite that runs with warnings as errors: a warning that the old version hid now fails a test.
What goes next: 3.16 and 3.17
Fix these now, while they are warnings, so the next upgrade is uneventful:
| Removal in | Item | Use instead |
|---|---|---|
| 3.16 | asyncio policies (set_event_loop_policy and related) |
asyncio.run(..., loop_factory=...), asyncio.Runner |
| 3.16 | asyncio.iscoroutinefunction() |
inspect.iscoroutinefunction() |
| 3.16 | ~True, ~False |
not x (or ~int(x) if you mean it) |
| 3.16 | logging handlers taking strm= |
stream= |
| 3.16 | array type code 'u' |
'w' |
| 3.16 | __loader__ set without __spec__.loader stops being consulted |
set __spec__; see the import system |
| 3.17 | the profile module |
profiling.tracing; see profiling |
| 3.17 | typing.ByteString, collections.abc.ByteString |
collections.abc.Buffer, or bytes | bytearray | memoryview |
| 3.17 | strptime() with %e and no year |
include a year |
Not everything that is deprecated will be removed. re.match() is soft deprecated in 3.15 in favour of re.prefixmatch(): it emits no warning and stays.
Testing across versions
Run your test suite on every version you claim to support, in CI, as a matrix. Locally, uv run --python 3.11 python -m pytest and the same with 3.15 run the suite under each interpreter, and tox or nox automate the matrix. Declare the range in requires-python, so installers refuse versions you have not tested.
Treat the free-threaded build as a real entry in that matrix: uv run --python 3.15t python -m pytest. It is officially supported in 3.15, though optional, and the official macOS installers now include it by default, so some of your users will run it. A job that is allowed to fail gets ignored. Once your project passes on 3.15t, a failure there is a regression like any other.
Getting ready for the free-threaded build
Passing tests on 3.15t is necessary but not enough, because most test suites run one thread. Work through this list once, then keep the CI job:
- Check the GIL really stays off. Import your package on
python3.15tand printsys._is_gil_enabled(). An extension module that has not declared support turns the GIL back on with aRuntimeWarning(the GIL lesson explains why). The free-threading compatibility tracker lists the state of popular dependencies. - Test with the GIL forced off.
PYTHON_GIL=0or-X gil=0keeps it off even after such an import. Use that only to find out what breaks, never in production. - Audit shared mutable state. Module-level caches, lazy singletons and class-level counters: the three shapes in architecture for large codebases. Built-in containers will not be corrupted, but check-then-act sequences still need a
threading.Lock. - Write a stress test. Run the operation that touches shared state from many threads at once, many times, and check an invariant at the end. Run it on both builds.
- Know the two flags that differ. On 3.15t,
sys.flags.thread_inherit_contextandsys.flags.context_aware_warningsare 1 by default (0 on the default build). New threads start with a copy of the caller’s context variables, andwarnings.catch_warnings()changes only the current context instead of a global list. Code that relied on either default behaves differently. - Build and declare. An extension needs
cp315twheels (cibuildwheel builds them) and thePy_mod_gilslot; packaging covers the wheel tags. - Measure. The free-threaded build runs single-threaded code a little slower (the free-threading HOWTO gives about 1% on macOS aarch64 to 8% on x86-64 Linux on the pyperformance suite). It pays only for parallel CPU-bound work in threads.
Adopting what is new
An upgrade is also the moment to use what the new version adds, once requires-python allows it: frozendict from immutability, sentinel and TypeForm from new in typing, and lazy imports. Template strings from the t-strings lesson need 3.14. Adopt them one at a time, in separate commits from the upgrade itself.
When code must differ by version, prefer testing for the feature, or catching ImportError, over comparing sys.version_info. The test then stays correct on other implementations and on versions you have not thought about:
import re
# re.prefixmatch is new in 3.15; re.match does the same job everywhere.prefixmatch = getattr(re, "prefixmatch", re.match)
print(bool(prefixmatch(r"\d+", "42 apples")))print(bool(prefixmatch(r"\d+", "apples 42")))TrueFalseNo runtime check can gate new syntax. A file containing lazy import or a t"..." string fails to compile on an older version before any if runs. For syntax, use __lazy_modules__ as the lazy imports lesson showed, or keep the new syntax in a module that only new versions import.
When not to upgrade yet
An application can move as soon as its dependencies install and its tests pass, ideally within a few months of a release. A library should not drop a version just because a newer one exists: support every version that still receives security fixes (3.11 to 3.15 today) and drop the oldest when it reaches end of life. And do not upgrade production on release day: wait for the first bugfix release, and for your dependencies to publish wheels.
Common pitfalls
- Upgrading the interpreter and the dependencies in one step. When something breaks, you cannot tell which change did it.
- Silencing deprecation warnings globally instead of with a narrow filter for the one dependency that causes them.
- Testing only on the newest version. Your
requires-pythonclaims the oldest one works too. - Assuming the UTF-8 change is harmless because tests pass on Linux. Test on Windows if you have Windows users.
This script worked on 3.12. On 3.15 it fails before printing anything:
from datetime import datetimefrom typing import NamedTuple
Payment = NamedTuple("Payment", day=str, amount=float)
rows = [("03-14", 12.5), ("03-02", 40.0)]payments = [Payment(*row) for row in rows]for payment in sorted(payments): day = datetime.strptime(payment.day, "%m-%d") print(f"{day:%d %b}: {payment.amount:6.2f}")The error is TypeError: NamedTuple() got an unexpected keyword argument 'day', and once that is fixed, the strptime call raises ValueError. Fix both. The data comes from 2026. Expected output:
02 Mar: 40.0014 Mar: 12.50Show solution
from datetime import datetimefrom typing import NamedTuple
class Payment(NamedTuple): day: str amount: float
rows = [("2026-03-14", 12.5), ("2026-03-02", 40.0)]payments = [Payment(*row) for row in rows]for payment in sorted(payments): day = datetime.strptime(payment.day, "%Y-%m-%d") print(f"{day:%d %b}: {payment.amount:6.2f}")02 Mar: 40.0014 Mar: 12.50The class syntax works on every supported version. Putting the year into the data rather than only into the format also fixes the sort: ISO dates sort correctly as strings across years, while "03-14" would have put next January before this March. On 3.14, both problems were already printed as DeprecationWarnings, which is why running tests with warnings as errors pays off.
Run each line on its own with python -W default -c "..." on 3.15. Which fail, which warn, and which are silent?
import sre_parsex = ~Trueimport re; re.match('a', 'abc')import profile- writing
'é'totempfile.TemporaryFile('w+')withoutencoding=
Expected: for each, “error”, “warning” or “silent”, with the exception or warning class.
Show solution
import reimport subprocessimport sys
SNIPPETS = [ "import sre_parse", "x = ~True", "import re; re.match('a', 'abc')", "import profile", "import tempfile\nwith tempfile.TemporaryFile('w+') as f: f.write('é')",]
for code in SNIPPETS: result = subprocess.run([sys.executable, "-W", "default", "-c", code], capture_output=True, text=True) found = re.findall(r"\b(\w+(?:Error|Warning))\b", result.stderr) first_line = code.splitlines()[0] print(f"{first_line:<34} exit {result.returncode}", found[-1] if found else "silent")import sre_parse exit 1 ModuleNotFoundErrorx = ~True exit 0 DeprecationWarningimport re; re.match('a', 'abc') exit 0 silentimport profile exit 0 DeprecationWarningimport tempfile exit 0 silentsre_parse is gone. ~True (removal in 3.16) and profile (removal in 3.17) still work but warn. re.match is only soft deprecated, so it is silent. The é is written as UTF-8 on every platform now, so nothing can go wrong and nothing is reported; with -X warn_default_encoding you would see an EncodingWarning.
Write a script that runs this legacy code in a child process with -W always::DeprecationWarning, prints the first sentence of each warning, and then the program’s own output:
import asyncioimport profile
flag = Trueprint(~flag, asyncio.iscoroutinefunction(print))Expected output:
The profile module is deprecated and will be removed in Python 3.17Bitwise inversion '~' on bool is deprecated and will be removed in Python 3.16'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16program output: -2 FalseShow solution
import subprocessimport sys
LEGACY = """import asyncioimport profile
flag = Trueprint(~flag, asyncio.iscoroutinefunction(print))"""
result = subprocess.run( [sys.executable, "-W", "always::DeprecationWarning", "-c", LEGACY], capture_output=True, text=True,)for line in result.stderr.splitlines(): if "DeprecationWarning" in line: message = line.split("DeprecationWarning: ")[1] print(message.split(";")[0].split(". ")[0])print("program output:", result.stdout.strip())The profile module is deprecated and will be removed in Python 3.17Bitwise inversion '~' on bool is deprecated and will be removed in Python 3.16'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16program output: -2 FalseThe warnings appear in the order the code runs: import profile before the print line. always shows a warning every time it is triggered, rather than once per location, which matters when you count them. On a real codebase, run the test suite this way, or with error, rather than a single file.
Summary
- Read What’s New (Removed, Deprecated, Porting) and the pending-removal pages; check your dependencies have wheels for the new version.
- Make
DeprecationWarningan error in tests while still on the old version, and fix what it finds. - 3.15 uses UTF-8 for text I/O everywhere (PEP 686). Find reliance on the old default with
-X warn_default_encoding; writeencoding="utf-8"explicitly. - 3.15 removed, among others,
strptime%dwithout a year, theNamedTuplekeyword form and thesre_*modules. The asyncio policy system and~boolgo in 3.16;profileandByteStringin 3.17. - Some 3.15 changes warn about nothing:
argparsedestinference, unpaddedurlsafe_b64decode,assertWarnsletting other warnings through, and keyword-onlysqlite3.connect()arguments. - Test every supported version in CI, with 3.15t as a real entry, and work through the free-threading checklist. Upgrade applications promptly but libraries conservatively.
- Next: the capstone project, which puts the whole expert level to work on one tool.