Python 3.15.0 was released on October 9, 2026. According to the release notes it contains 5,643 commits from 1,012 contributors, and unlike some recent releases, many of its headline features are things you will use in ordinary application code, not only in the interpreter internals.
Three changes will touch most codebases. Lazy imports finally have real syntax, which matters for CLI startup time and large applications. UTF-8 is now the default encoding everywhere, which removes a whole class of "works on my Mac, breaks on Windows" bugs, and may break code that relied on the old behavior. And there is a new sampling profiler, Tachyon, that can attach to a running process with no code changes.
This cheatsheet goes through what is new, with short examples, and ends with an upgrade checklist.
1. At a glance
| Area | Change | PEP |
|---|---|---|
| Imports | lazy import and lazy from ... import syntax |
810 |
| Built-ins | frozendict: immutable, hashable mapping |
814 |
| Built-ins | sentinel: unique sentinel values with a clean repr |
661 |
| Syntax | * and ** unpacking in comprehensions |
798 |
| I/O | UTF-8 is the default encoding (UTF-8 mode on by default) | 686 |
| Startup | .start files for package startup hooks |
829 |
| Profiling | New profiling package; Tachyon sampling profiler |
799 |
| Typing | TypedDict with typed extra items; TypeForm; disjoint bases |
728, 747, 800 |
| Performance | Experimental JIT upgraded: 7-8% faster on x86-64 Linux, 11-12% on AArch64 macOS (geometric mean) | - |
| Observability | Frame pointers enabled by default | 831 |
| C API | Stable ABI for free-threaded builds (abi3t) |
803, 820, 793 |
| Errors | Better AttributeError suggestions |
- |
2. Explicit lazy imports (PEP 810)
Large Python applications often spend a surprising share of their startup time importing modules they may never use in that run. A CLI that imports pandas, boto3 and an HTTP client at the top takes seconds to print --help. Until now, the fixes were ugly: imports inside functions, or import hooks with global side effects.
Python 3.15 adds a soft keyword:
lazy import json
lazy from pathlib import Path
print("Starting up...") # json and pathlib not imported yet
data = json.loads('{"key": "value"}') # json is actually imported here
A lazy import binds the name immediately, but the module is only loaded on first use. If you never use it, you never pay for it.
Rules and restrictions
- Module scope only. Not inside functions, classes, or
try/exceptblocks. (Imports inside functions are already lazy in practice.) - No star imports.
lazy from module import *is aSyntaxError. - No future imports.
lazy from __future__ import ...is aSyntaxError. - Errors move. An
ImportErrorfor a missing module appears at first use, not at theimportline. Keep that in mind when you debug. - Side effects move too. If a module registers plugins or patches something when imported, making it lazy delays that.
Global control
python -X lazy_imports=all app.py # treat all imports as lazy
PYTHON_LAZY_IMPORTS=all python app.py # same, via environment variable
The default mode, normal, respects only explicit lazy keywords. There is also a runtime API: sys.set_lazy_imports(), sys.get_lazy_imports(), and sys.set_lazy_imports_filter() to make only some modules lazy:
import sys
def myapp_filter(importing, imported, fromlist):
return imported.startswith("myapp.")
sys.set_lazy_imports_filter(myapp_filter)
sys.set_lazy_imports("all")
import myapp.slow_module # lazy (matches the filter)
import json # eager
Supporting older Pythons
Libraries that must run on 3.14 and earlier cannot use the lazy keyword, because older parsers reject it. PEP 810 adds a module-level declaration that older versions simply ignore:
__lazy_modules__ = ["json", "pathlib"]
import json # lazy on 3.15+, eager on older versions
import os # eager
Where to start: CLIs and services with slow startup. Measure first with python -X importtime -c "import yourapp", then make the heaviest imports lazy.
3. frozendict (PEP 814)
An immutable, hashable mapping, built in at last.
>>> a = frozendict(x=1, y=2)
>>> a
frozendict({'x': 1, 'y': 2})
>>> a["z"] = 3
TypeError: 'frozendict' object does not support item assignment
>>> b = frozendict(y=2, x=1)
>>> a == b, hash(a) == hash(b)
(True, True)
Properties worth knowing:
- Hashable if all keys and values are hashable, so it works as a dict key, in a set, or as an argument to a
functools.lru_cachefunction. - Preserves insertion order, but equality ignores order, like
dict. - Not a subclass of
dict. Code that checksisinstance(x, dict)will reject it. Check forcollections.abc.Mappinginstead, or(dict, frozendict). - Supported by
copy,json,pickle,pprintand several other standard library modules.
Typical uses: configuration objects that must not change after load, default argument values (no more mutable-default bugs), cache keys made from options, and constants shared across threads.
DEFAULT_HEADERS = frozendict({"User-Agent": "myapp/1.0", "Accept": "application/json"})
def fetch(url: str, headers: frozendict = DEFAULT_HEADERS) -> bytes:
merged = {**headers, "X-Request-Id": new_id()} # copy into a dict to modify
...
4. sentinel (PEP 661)
Every Python codebase eventually writes _MISSING = object() to tell "no argument given" apart from None. That works, but the repr is ugly (<object object at 0x7f...>), it does not survive pickling, and type checkers cannot express it well. Python 3.15 adds a built-in:
MISSING = sentinel("MISSING")
def read_value(key: str, default: int | MISSING = MISSING) -> int:
if key in store:
return store[key]
if default is MISSING:
raise KeyError(key)
return default
- Clean repr:
MISSING. - Identity survives
copyand pickling (when importable by module and name). - Usable in type expressions with
|, as above. - Type checkers expect the name passed to
sentinel()to match the variable it is assigned to.
5. Unpacking in comprehensions (PEP 798)
Flattening with a nested comprehension has always read backwards. Now you can unpack directly:
>>> lists = [[1, 2], [3, 4], [5]]
>>> [*L for L in lists] # was: [x for L in lists for x in L]
[1, 2, 3, 4, 5]
>>> sets = [{1, 2}, {2, 3}, {3, 4}]
>>> {*s for s in sets}
{1, 2, 3, 4}
>>> dicts = [{"a": 1}, {"b": 2}, {"a": 3}]
>>> {**d for d in dicts} # later keys win
{'a': 3, 'b': 2}
>>> gen = (*L for L in lists) # generator expressions too
>>> list(gen)
[1, 2, 3, 4, 5]
A small change, but it makes "merge all of these" code read the way you think about it.
6. UTF-8 by default (PEP 686)
Until 3.14, open("file.txt") without an encoding argument used the locale encoding: usually UTF-8 on Linux and macOS, but often a legacy code page such as cp1252 on Windows. The same code read files differently depending on the machine. In 3.15, UTF-8 mode is on by default, so text I/O without an explicit encoding uses UTF-8 everywhere.
| Code | 3.14 and earlier | 3.15 |
|---|---|---|
open("f.txt") |
Locale encoding | UTF-8 |
open("f.txt", encoding="utf-8") |
UTF-8 | UTF-8 |
open("f.txt", encoding="locale") |
Locale encoding | Locale encoding |
Path("f.txt").read_text() |
Locale encoding | UTF-8 |
What to do:
- Most code gets better for free, especially on Windows.
- Code that really needs the locale encoding (reading files produced by legacy Windows tools, for example) should say
encoding="locale", available since Python 3.10. - To opt out temporarily:
PYTHONUTF8=0orpython -X utf8=0. - Best practice is unchanged: pass
encoding=explicitly. Explicit code behaves the same on every version.
7. The profiling package and Tachyon (PEP 799)
Python 3.15 reorganizes profilers into a profiling package:
| Module | What it is |
|---|---|
profiling.tracing |
The deterministic tracing profiler (what cProfile was). cProfile stays as an alias. |
profiling.sampling |
Tachyon, a new high-frequency statistical sampling profiler |
profile |
The old pure-Python profiler: deprecated, scheduled for removal in 3.17 |
Tachyon samples stacks at a high rate instead of tracing every call, so its overhead is low enough for real workloads, and it can attach to a running process:
# Profile a script or a module
python -m profiling.sampling run script.py
python -m profiling.sampling run -m mypackage.cli
# Attach to a running process (e.g. a stuck web worker)
python -m profiling.sampling attach 12345
# One-off snapshot of all thread stacks, async-aware
python -m profiling.sampling dump 12345 --async-aware
# What to measure
python -m profiling.sampling run --mode cpu script.py # CPU time only
python -m profiling.sampling run --mode gil script.py # time holding the GIL
python -m profiling.sampling run --mode wall script.py # wall clock (default)
# Output formats
python -m profiling.sampling run --flamegraph script.py # self-contained HTML flame graph
python -m profiling.sampling run --live script.py # top-like live view
python -m profiling.sampling run --pstats script.py # pstats-compatible stats
Other options include --all-threads, --gecko (Firefox Profiler format), --collapsed (for external flame graph tools), --heatmap and --opcodes.
This closes a long-standing gap: tools such as py-spy filled it for years, and now a capable sampling profiler ships with the interpreter. Combined with frame pointers enabled by default (PEP 831), system profilers like perf and eBPF-based tools can also produce reliable Python stacks.
8. Typing additions
| Feature | What it does |
|---|---|
TypedDict extra items (PEP 728) |
closed=True forbids keys not declared; extra_items=T allows extra keys with values of type T |
TypeForm (PEP 747) |
Annotates parameters that accept a type expression as a value, e.g. a function cast_to(tp: TypeForm[T], value) -> T |
| Disjoint bases (PEP 800) | Lets type checkers know that certain classes cannot be combined in multiple inheritance, improving narrowing |
from typing import TypedDict
class Movie(TypedDict, extra_items=bool):
name: str
m: Movie = {"name": "Blade Runner", "novel_adaptation": True} # OK
bad: Movie = {"name": "Blade Runner", "year": 1982} # type error: int is not bool
class Point(TypedDict, closed=True):
x: int
y: int
# {"x": 1, "y": 2, "z": 3} is now a type error
Closed TypedDicts are especially useful for JSON payloads and for LLM structured outputs, where an unexpected key usually means a bug.
9. Performance and the JIT
- The experimental JIT has been 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: benchmark your own workload before enabling it in production.
- The official Windows 64-bit binaries now use the tail-calling interpreter, a faster interpreter design introduced in 3.14.
- The official macOS installers now include free-threading support by default.
- C extensions can target a new Stable ABI for free-threaded builds (
abi3t), so one binary wheel can work across free-threaded Python versions. Expect library support to arrive gradually. bytearray.take_bytes()takes bytes out of abytearraywithout copying, useful in I/O-heavy code.
10. Smaller things worth knowing
- Better
AttributeErrormessages, including suggestions from other languages and nested attributes:
>>> [1, 2, 3].push(4)
AttributeError: 'list' object has no attribute 'push'. Did you mean '.append'?
>>> {}.put("a", 1)
AttributeError: 'dict' object has no attribute 'put'. Use d[k] = v.
>>> print(container.area)
AttributeError: 'Container' object has no attribute 'area'. Did you mean '.inner.area' instead of '.area'?
math.integer, a new module for integer-specific math functions (PEP 791), plusmath.isnormal(),math.issubnormal(),math.fmax(),math.fmin()andmath.signbit().json.load()andjson.loads()accept anarray_hookto customize how JSON arrays are decoded.pathlib.Path.mkdir()getsparent_modeto set permissions on intermediate directories.re.prefixmatch()is added as a clearer name forre.match(), which is soft-deprecated (it keeps working)..startfiles (PEP 829) give packages a declared startup hook (pkg.mod:callable) instead ofimportlines in.pthfiles.__cached__is no longer set on modules; use__spec__.cached.
11. Upgrade checklist
| Step | Why |
|---|---|
| Run your test suite on 3.15 in CI, alongside your current version | Removals and the encoding change show up here first |
Search for open( and read_text( without encoding= |
Behavior changes on Windows and in non-UTF-8 locales |
Check isinstance(..., dict) checks if you plan to use frozendict |
frozendict is not a dict subclass |
| Check that your dependencies publish 3.15 wheels | Especially packages with C extensions |
Replace profile module usage |
Deprecated, scheduled for removal in 3.17 |
Measure startup with -X importtime and try lazy imports on the slowest |
Usually the quickest visible win |
| Replace homegrown sentinels in new code | Cleaner repr, pickling and typing |
Keep encoding= and -X utf8 in mind for legacy file formats |
encoding="locale" when you really need the old behavior |
| Try Tachyon on a production-like workload | Low-overhead profiling without code changes |
For libraries: add 3.15 to your test matrix and classifiers, use __lazy_modules__ if you want lazy imports while still supporting older versions, and consider abi3t wheels if you ship C extensions and want free-threaded users.
Why this matters
- Lazy imports fix slow startup with one keyword, without restructuring code.
- UTF-8 by default removes cross-platform encoding bugs, and can surface code that relied on the locale.
frozendictandsentinelreplace common homegrown patterns with built-ins that work with pickling and type checkers.- Tachyon brings production-grade sampling profiling into the standard library, with attach-to-process support.
- The JIT and free-threading keep maturing, but measure before you depend on them.
Final Thoughts
Python 3.15 is a release of practical improvements. None of the headline features require you to rethink how you write Python, and most of them remove friction you have probably felt: slow CLI startup, encoding surprises on Windows, object() sentinels, and reaching for an external tool every time you need to profile something that is already running.
Upgrade in CI first, fix the encoding warnings, then pick the low-hanging fruit: lazy imports for startup, frozendict for configuration, Tachyon for the next performance question. The interpreter is getting faster in the background; these are the parts you can use today.
Sources
- Python Software Foundation, Python 3.15.0 release page (October 9, 2026): release date, commit and contributor counts, feature list, JIT performance figures, Windows and macOS binary changes.
- Python documentation, What's New In Python 3.15: lazy import syntax and controls,
frozendict,sentinel, comprehension unpacking, UTF-8 mode, profiling package and Tachyon CLI, error messages, standard library changes, removals. - PEP 810: Explicit lazy imports.
- PEP 814: Add frozendict built-in type.
- PEP 661: Sentinel Values.
- PEP 798: Unpacking in Comprehensions.
- PEP 686: Make UTF-8 mode default.
- PEP 799: A dedicated profiling package for organizing Python profiling tools.
- PEP 728: TypedDict with Typed Extra Items and PEP 747: Annotating Type Forms.
- PEP 790: Python 3.15 Release Schedule.