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/except blocks. (Imports inside functions are already lazy in practice.)
  • No star imports. lazy from module import * is a SyntaxError.
  • No future imports. lazy from __future__ import ... is a SyntaxError.
  • Errors move. An ImportError for a missing module appears at first use, not at the import line. 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_cache function.
  • Preserves insertion order, but equality ignores order, like dict.
  • Not a subclass of dict. Code that checks isinstance(x, dict) will reject it. Check for collections.abc.Mapping instead, or (dict, frozendict).
  • Supported by copy, json, pickle, pprint and 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 copy and 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=0 or python -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 a bytearray without copying, useful in I/O-heavy code.

10. Smaller things worth knowing

  • Better AttributeError messages, 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), plus math.isnormal(), math.issubnormal(), math.fmax(), math.fmin() and math.signbit().
  • json.load() and json.loads() accept an array_hook to customize how JSON arrays are decoded.
  • pathlib.Path.mkdir() gets parent_mode to set permissions on intermediate directories.
  • re.prefixmatch() is added as a clearer name for re.match(), which is soft-deprecated (it keeps working).
  • .start files (PEP 829) give packages a declared startup hook (pkg.mod:callable) instead of import lines in .pth files.
  • __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.
  • frozendict and sentinel replace 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