Python 3.15.0 was released on 9 October 2026, right on the date set in its release schedule. It is a feature-heavy release: two new built-in types, a new keyword for lazy imports, a new sampling profiler, unpacking inside comprehensions, and one change that can quietly alter how existing code reads and writes files. This post walks through the changes most application developers will actually touch, with short examples, and ends with a practical checklist for upgrading.
The timing matters too. Python 3.10 reached end of life on 1 October 2026, with 3.10.22 as its final release. If you are still on 3.10, you are now choosing between 3.14 and 3.15, and that decision is easier once you know what 3.15 changes.
Release status and support window
According to PEP 790, the 3.15 release schedule, 3.15 gets bugfix releases roughly every two months for two years, followed by security-only releases until approximately October 2031. That gives a project starting today a long runway on 3.15.
The official binaries also changed in this release. The Windows 64-bit installer now uses the tail-calling interpreter, and the macOS installer installs free-threading support by default. Free-threaded Python is still a separate build variant (with its own cp315t ABI tag), not the default interpreter.
UTF-8 is now the default encoding (PEP 686)
This is the change most likely to affect existing code, and it is easy to miss because nothing fails loudly. In 3.15, Python uses UTF-8 by default regardless of the system locale. A call like open("data.txt") without an encoding argument now reads and writes UTF-8 everywhere.
On Linux and macOS, where the locale is usually UTF-8 already, you will probably notice nothing. On Windows, where the default used to be the legacy code page (often cp1252), the behaviour changes. A script that wrote a CSV with Urdu or accented names on Windows under 3.14 may have written it in the locale encoding; the same script on 3.15 writes UTF-8. Reading older files produced that way can now raise UnicodeDecodeError or produce garbled text.
The fix is the one the Python docs have recommended for years: pass the encoding explicitly.
with open("customers.csv", encoding="utf-8") as f:
rows = f.read()
# If you really need the old behaviour for one file:
with open("legacy-export.txt", encoding="locale") as f:
text = f.read()
To find places in your code that rely on the default, run your test suite with the opt-in warning that has existed since Python 3.10:
python -X warn_default_encoding -m pytest
Every open() or similar call without an explicit encoding will emit an EncodingWarning. If you need time before fixing everything, you can opt out of UTF-8 mode for the whole process with the PYTHONUTF8=0 environment variable or -X utf8=0, but treat that as a temporary measure.
Explicit lazy imports (PEP 810)
Large applications and CLI tools often spend a noticeable part of their startup time importing modules they may never use in a given run. Python 3.15 adds a soft keyword, lazy, that defers the actual import until the name is first used:
lazy import json
lazy from pathlib import Path
print("Starting up...") # json and pathlib are not imported yet
data = json.loads('{"key": "value"}') # json is imported here
p = Path(".") # pathlib is imported here
There are some deliberate restrictions. lazy is only allowed at module scope: using it inside a function, a class body, or a try/except/finally block is a SyntaxError, as are lazy from module import * and lazy from __future__ import .... If a lazily imported module fails to load, the exception is raised at the point of first use, and the traceback shows both that location and the original import statement.
Because lazy import is new syntax, it will not parse on older Python versions. Libraries that need to support 3.14 and earlier can use the __lazy_modules__ list instead, which older interpreters simply ignore:
__lazy_modules__ = ["json", "pathlib"]
import json # lazy on 3.15+
import os # still eager
There is also a global switch. The PYTHON_LAZY_IMPORTS environment variable (and the matching -X lazy_imports option) accepts all to make every import lazy, or normal, the default, which only honours the keyword. For finer control, sys.set_lazy_imports_filter() takes a callable that decides per import whether lazy loading is allowed.
My recommendation is to start small: mark the heavy, rarely used imports in your CLI entry points or Django/FastAPI management commands as lazy, measure startup time with python -X importtime, and only then consider broader use. Modules that do important work at import time (registering plugins, patching other libraries) should stay eager.
New built-ins: frozendict and sentinel
frozendict (PEP 814)
frozendict is an immutable mapping. It keeps insertion order, but equality and hashing ignore order, and it is hashable as long as its keys and values are. That makes it usable as a dictionary key, a set member, or a cache key for functools.lru_cache.
>>> a = frozendict(x=1, y=2)
>>> b = frozendict(y=2, x=1)
>>> a == b, hash(a) == hash(b)
(True, True)
>>> a["z"] = 3
TypeError: 'frozendict' object does not support item assignment
One detail to remember: frozendict is not a subclass of dict. Code that checks isinstance(value, dict) will reject it. The docs suggest checking against collections.abc.Mapping, or (dict, frozendict), when you want to accept both.
sentinel (PEP 661)
Python code has long used MISSING = object() to tell “no argument given” apart from None. That works, but the value has an unhelpful repr, does not survive pickling, and is awkward to express in type hints. The new sentinel built-in fixes all three:
MISSING = sentinel("MISSING")
def read_value(key: str, default: int | MISSING = MISSING) -> int:
if default is MISSING:
... # no default was passed
...
The signature is sentinel(name, /, *, repr=...). The repr is just the name (so it shows as MISSING in tracebacks and debuggers), sentinels can be pickled when importable by module and name, and type checkers can narrow on value is MISSING.
Unpacking in comprehensions (PEP 798)
Flattening a list of lists has always needed a double for in a comprehension. In 3.15 you can unpack directly:
>>> lists = [[1, 2], [3, 4], [5]]
>>> [*L for L in lists]
[1, 2, 3, 4, 5]
>>> dicts = [{"a": 1}, {"b": 2}, {"a": 3}]
>>> {**d for d in dicts}
{'a': 3, 'b': 2}
It works the same way in set comprehensions and generator expressions, including async ones. It is a small change, but merging a list of config dictionaries with {**d for d in layers} reads more clearly than the nested version.
A built-in sampling profiler (PEP 799)
Profiling tools now live in a new profiling package. profiling.tracing is the deterministic profiler previously known as cProfile (which remains as an alias), and profiling.sampling is a new statistical sampling profiler. The old pure-Python profile module is deprecated and scheduled for removal in 3.17.
The sampling profiler is the interesting part, because it can attach to a process that is already running, which is exactly what you want when a production worker is suddenly slow:
# Profile a script from the start and write an HTML flame graph
python -m profiling.sampling run --flamegraph -o profile.html app.py
# Attach to a running process by PID, with a live terminal view
python -m profiling.sampling attach --live 12345
It supports several modes (wall-clock, CPU, GIL and exception), can be made asyncio-aware, and exports to formats including pstats, collapsed stacks and Firefox Profiler (gecko). Attaching needs permission to read another process’s memory: on Linux that usually means root, CAP_SYS_PTRACE, or relaxing the Yama ptrace_scope setting; on macOS it requires root or a debugger entitlement. The profiler and the target must also be the same Python minor version. Inside Docker containers you will typically need to add the SYS_PTRACE capability to the container.
The JIT, typing and other changes
The experimental JIT compiler was significantly upgraded. The release notes report a 7-8% geometric mean improvement on x86-64 Linux over the standard interpreter, and 11-12% on AArch64 macOS over the tail-calling interpreter. It is still experimental and off by default: you enable it at runtime with PYTHON_JIT=1 on a build that includes it, and you can check with sys._jit.is_enabled() (an underscore-prefixed implementation detail, so do not depend on it in production code). Benchmark your own workload before drawing conclusions.
Typing gets TypedDict with typed extra items (PEP 728), TypeForm for annotating type expressions (PEP 747), and disjoint bases (PEP 800). Frame pointers are now enabled by default in builds (PEP 831), which makes system profilers like perf more useful. Error messages and colour output in the REPL and tools were improved again.
A few smaller behaviour changes can show up in tests:
argparse.ArgumentParsernow defaults tosuggest_on_error=True, so error output for mistyped options includes suggestions.importlib.metadataraisesMetadataNotFoundinstead of returning empty metadata when the metadata file is missing.array.typecodesis now a tuple rather than a string.- Import lines in
.pthfiles are quietly deprecated in favour of new.startfiles (PEP 829), which name an entry point aspkg.mod:callable. - Several long-deprecated APIs were removed from modules including
ast,pathlib,typing,threadingandhttp.server; check the “Removed” section of What’s New for anything your code uses.
Should you upgrade now?
For new projects and internal tools, 3.15 is a reasonable choice today. For production services with many dependencies, the usual advice applies: wait until the packages you rely on publish 3.15 wheels, especially anything with C extensions (NumPy, Pandas, database drivers, cryptography). Many projects publish them within weeks, but not all at once.
A practical upgrade checklist:
- Run your test suite on 3.14 with
-X warn_default_encodingand add explicitencoding=arguments wherever warnings appear. This is worth doing even before you switch. - Run with
-W error::DeprecationWarningon 3.14 to catch APIs that 3.15 removed. - Add 3.15 to your CI matrix (for example, a new entry in your GitHub Actions
python-versionlist) and see what breaks. - Check that your key dependencies have 3.15 wheels on PyPI.
- Update your Docker base image to a
python:3.15tag once your dependencies are ready. - Only then start adopting new features such as
lazyimports andfrozendict, remembering that they make your code require 3.15.
If you are still on 3.10, do not wait: it no longer receives security fixes. Moving to 3.14 first is a perfectly sensible step if your dependencies are not ready for 3.15 yet; the encoding cleanup above will make the later jump to 3.15 straightforward.
Sources: Python 3.15.0 release page, What’s New in Python 3.15, PEP 790 – Python 3.15 Release Schedule, PEP 661 – Sentinel Values, profiling.sampling documentation, Real Python: Python 3.15 JIT compiler, LWN.net: Python 3.15 released, LinuxCompatible: Python 3.10 reaches end of life