Python 3.15 landed on 9 October 2026, and it is the kind of release you actually feel: 5,643 commits from 1,012 contributors, release-managed by Hugo van Kemenade, with a headline feature — explicit lazy imports — that can make your CLI tools and serverless functions start noticeably faster. This guide covers what is new in Python 3.15, what might break in your code, and the exact steps to try it safely today.
Unlike some recent releases that were mostly runtime plumbing, Python 3.15 gives you things you will reach for in everyday code: a new lazy keyword, a built-in immutable dictionary, a proper sentinel type, UTF-8 as the default encoding, a profiler you can attach to a running process, and comprehension syntax that finally lets you unpack. Here is each one, with code you can run.
Install Python 3.15 alongside your current version
Do not replace your system Python. Install 3.15 side by side and test it:
# Fastest route: uv ships its own builds, no compilation needed
uv python install 3.15
uv run --python 3.15 python --version # Python 3.15.0
# Or grab the official installers from python.org
# macOS / Windows 64-bit installers are on the release page,
# and Linux users can use pyenv or the deadsnakes PPA.
Then pin a project to it and run your test suite before changing anything else:
uv venv --python 3.15
uv pip install -r requirements.txt # or uv sync, your choice
pytest
If you are still managing interpreters with pyenv or system packages, the uv vs Poetry comparison on this blog walks through why uv has become the path of least resistance for this.
Lazy imports: the headline feature of Python 3.15
Large Python programs often waste seconds at startup. The culprit is the import system: when you import a module, Python locates the file, reads it from disk, compiles it to bytecode, and runs all of its top-level code — even if your run never touches most of it. The traditional workarounds are moving imports inside functions or using importlib to load things on demand, both of which scatter imports around your codebase and rely on discipline.
PEP 810 adds an explicit lazy soft keyword. Mark an import as lazy and Python defers the real loading until the name is first used, while the import still lives at the top of the file where it belongs:
lazy import json
lazy from pathlib import Path
print("Starting up...") # json and pathlib are not fully imported yet
data = json.loads('{"key": "value"}') # json is fully imported here
p = Path(".") # pathlib is fully imported here
Under the hood, lazy import json binds the name to a lightweight proxy object, and the module loads transparently on first access. The biggest winners are command-line tools that pull in half the standard library just to print --help, test suites, and dependency-heavy serverless functions where cold starts cost you money.
The rules you need to know
- Module scope only. Using
lazyinside a function, class body, ortry/exceptblock raisesSyntaxError. Neither star imports (lazy from m import *) nor future imports can be lazy. - Failure happens at first use, not at import time. If the module does not exist, the exception is raised when you touch the name — and the traceback includes both the access location and the original import statement, so it is straightforward to diagnose.
- You can go global without editing source. The
-X lazy_importscommand-line option and thePYTHON_LAZY_IMPORTSenvironment variable acceptall(everything lazy) ornormal(the default: respect thelazykeyword).sys.set_lazy_imports()andsys.get_lazy_imports()control it at runtime, andsys.set_lazy_imports_filter()takes a callable so you can, say, make only your own app’s modules lazy while keeping third-party dependencies eager. - Version-compatible projects have an escape hatch. If your code must still run on Python older than 3.15, define
__lazy_modules__as a list of fully qualified module names; regularimportstatements for those modules are then treated as lazy on 3.15+ and ignored elsewhere.
To find out whether lazy imports actually help your app, measure before and after:
python -X importtime mycli.py 2> import.log # baseline
# add `lazy` to your heavy third-party imports, then:
python -X importtime mycli.py 2> import-lazy.log
time python mycli.py --help
UTF-8 is now the default encoding
Under PEP 686, Python now uses UTF-8 as the default text encoding regardless of the system’s locale. A bare open('notes.txt') reads and writes UTF-8 everywhere — Linux, macOS, and Windows alike. This kills a long-standing family of Windows-only bugs where files written on one machine could not be read on another because the locale-dependent default encoding differed.
The change only applies when no encoding argument is given. Code that was already passing encoding= explicitly is unaffected, and the official guidance is to keep doing exactly that for maximum compatibility between Python versions.
The migration checklist
- Audit bare
open()calls. Search your codebase foropen(without anencodingargument. Every hit is a place whose behaviour changed. There is an opt-in encoding warning you can enable to find affected code, and the specialencoding="locale"argument (available since Python 3.10) gives you the old locale-based behaviour on a per-call basis. - Test on Windows first. This is where the behaviour change bites hardest — programs that relied on the Windows default encoding (like cp1252) to read legacy files will now decode them as UTF-8 instead.
- Know the escape hatch. If you need the previous behaviour globally,
PYTHONUTF8=0orpython -X utf8=0disables UTF-8 mode. Treat this as a temporary bridge while you fix the code, not a permanent setting.
frozendict is a built-in now
PEP 814 adds an immutable dictionary to the builtins module — no more reaching for types.MappingProxyType or third-party packages:
>>> a = frozendict(x=1, y=2)
>>> a
frozendict({'x': 1, 'y': 2})
>>> a['z'] = 3
Traceback (most recent call last):
...
TypeError: 'frozendict' object does not support item assignment
A frozendict is not a subclass of dict — it inherits directly from object — but it preserves insertion order, and it is hashable as long as all of its keys and values are hashable. That makes it ideal for configuration objects that must not be mutated by accident, caches keyed on their contents, and any place you currently build a dict once and then freeze it with MappingProxyType.
Two practical notes. First, code that guards with isinstance(arg, dict) will now reject frozendicts — update such checks to isinstance(arg, (dict, frozendict)), or better, to collections.abc.Mapping, which also covers MappingProxyType. Second, the standard library has already been updated to accept frozendicts in copy, decimal, json, marshal, pickle, pprint, and xml.etree.ElementTree, and eval()/exec() accept them for globals — so they slot into existing plumbing without friction.
sentinel ends the _MISSING = object() era
Every Python developer has written this:
_MISSING = object()
def get(key, default=_MISSING):
if default is _MISSING:
raise KeyError(key)
return default
It works, but a bare object() prints as <object object at 0x...> — useless in a debugger or a traceback — and nothing stops two modules’ _MISSING values from being silently confused with each other. PEP 661 adds a sentinel built-in type for creating unique, named marker values:
MISSING = sentinel("MISSING")
MISSING
# MISSING
def get(key, default=MISSING):
if default is MISSING:
raise KeyError(key)
return default
Sentinels preserve identity when copied, work in type expressions with the | operator (so int | MISSING builds a proper union), and can be pickled when they are importable by module and name. Name the variable exactly what you pass to the constructor, otherwise pickling will not round-trip.
Tachyon: a profiler you can attach to a running process
PEP 799 gathers Python’s built-in profiling tools under one profiling package: profiling.tracing is deterministic function-call tracing (relocated from cProfile, which stays as a backwards-compatible alias), and profiling.sampling is the new star — Tachyon, a statistical sampling profiler.
What makes Tachyon different is that it attaches to an already-running Python process without restarting it or modifying any code, and it samples at up to 1,000,000 Hz — the fastest sampling profiler available for Python at the time of its contribution, per the release notes. That makes it practical for debugging performance problems in production, where traditional profilers would be too intrusive.
- Attach to a live process: point it at a PID and start collecting — zero overhead while attached, no code changes.
- Profile from the start: run a script or a module directly under it (
run/run -mmodes). - Dump a hung process: one-shot mode prints a traceback-style stack of every thread — or every asyncio task with
--async-aware— which is exactly what you want when a worker goes silent at 3 a.m. - Three measurement modes: wall-clock time (the default, includes I/O waits), CPU time (excludes I/O and blocking — use it for CPU-bound bottlenecks), and GIL-holding time (finds which threads dominate the GIL in multi-threaded apps).
One deprecation to note: the pure-Python profile module is deprecated and will be removed in Python 3.17. If you still have scripts importing it, this is your two-version warning.
Also in Python 3.15: the quick hits
- Unpacking in comprehensions (PEP 798). List, set, and dict comprehensions — plus generator expressions — now support
*and**unpacking, replacing nested comprehensions anditertools.chain()calls:lists = [[1, 2], [3, 4], [5]][*L for L in lists] # [1, 2, 3, 4, 5]{**d for d in [{'a': 1}, {'b': 2}]} # {'a': 1, 'b': 2} - A faster experimental JIT. The upgraded JIT delivers 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, so most people installing 3.15 today will not touch it — but it is clearly becoming a real feature.
- Better error messages. A failed attribute lookup can now suggest a nested attribute (
Did you mean '.inner.area' instead of '.area'?), and when there is no close match, the interpreter checks a table of common method names from JavaScript, Java, Ruby, and C# and suggests the Python equivalent —.push()becomes.append(),.toUpperCase()becomes.upper(). - Frame pointers on by default (PEP 831). System profilers like
perfare far more useful against Python processes now. - Typing gets three upgrades: PEP 728 (
TypedDictwith typed extra items), PEP 747 (TypeFormfor annotating type forms — good news for validation libraries), and PEP 800 (disjoint bases in the type system). - Installer changes. The official macOS installers now include free-threading (no-GIL) support by default, and the official Windows 64-bit binaries use the tail-calling interpreter. Extension authors also get a stable ABI for free-threaded builds (
abi3t), so one wheel can eventually cover no-GIL Python 3.15 and later.
Should you upgrade now?
- New projects: start on 3.15. The feature set is stable and the ecosystem will converge on it quickly.
- Existing apps: run your suite under 3.15 in CI first. The two real risk areas are the UTF-8 default (if any code reads files without an explicit encoding) and C extensions (free-threading ABI changes may affect native wheels you depend on).
- Production servers: if you are conservative, wait for 3.15.1 — x.0 releases historically pick up a round of real-world fixes in the first patch release — but do the UTF-8 audit now, because that change is not going away.
- Skip lazy imports where correctness is unclear. The mechanism is opt-in and safe, but proxy semantics can surprise libraries that do exotic things with module objects. Profile first, mark imports lazy where the startup win is real, and leave the rest alone.
Python 3.15 is a practical release rather than a disruptive one: nothing you wrote stops working, but a handful of things you wrote get faster, clearer, or easier to express. Install it, run your tests, and mark your heavy imports lazy — that alone is worth the upgrade.
Further Reading & References
- What’s New in Python 3.15 — the official release notes, the source of every fact in this post
- Python 3.15.0 (final) is here — the official announcement on discuss.python.org
- Python 3.15 Released with Faster JIT, Lazy Imports, and UTF-8 by Default — Linuxiac’s overview
- Python 3.15 released with lazy imports and UTF-8 default — BetaNews coverage of the release
- Python 3.15 Lands With Lazy Imports and a JIT That Is Finally Pulling Its Weight — Hardware Busters’ deep dive, including the RC3 lazy-import saga
- Python 3.15.0 ships with lazy imports and frozendict — DEV Community summary
- TIL #144 – Sentinel built-in — a concise explainer of the new sentinel builtin with examples
- compat-sentinel on PyPI — a backport of PEP 661’s sentinel for Python 3.10+, if you need the pattern on older interpreters



