Python 3.15.0 shipped on October 9 — 5,643 commits from 1,012 contributors, and a week late because a surprise third release candidate was needed to fix late bugs in its headline feature: explicit lazy imports. I installed Python 3.15.1 (via uv, next to this machine's system Python 3.12.3) and measured the headline, then probed the smaller features, because the small print is where upgrades actually bite.
Lazy imports: 164 ms → 28 ms, and the bill arrives later
The syntax is a new keyword in front of the statement: lazy import json. The module isn't loaded when the statement runs; it's loaded on first use.
To make the cost visible I wrote a module that sleeps for 250 ms at import time, then ran three variants of a program that never needs it (medians of 9 runs):
# heavy.py
import time
time.sleep(0.25)
MARKER = "loaded"
eager_unused.py (import heavy) median 270 ms
lazy_unused.py (lazy import heavy) median 17 ms 'heavy' in sys.modules: False
lazy_used.py (lazy + touch MARKER) median 270 ms in sys.modules after touch: True
No surprises there — deferral works exactly as advertised. The more realistic test is a CLI-shaped script that imports eleven stdlib modules at the top but only uses one of them on the path it actually runs (json, re, tarfile, zipfile, sqlite3, unittest, pdb, http.server, email, logging.config, xml.etree.ElementTree; only json is touched). Medians of 15 runs:
cli_eager.py median 164.1 ms
cli_lazy.py median 28.4 ms
A 5.8x faster startup, from adding one word eleven times. For CLI tools and short-lived scripts — the programs whose entire job can be shorter than their import time — this is the real deal, no caveats about the measurement itself.
The caveat is about when errors happen. This is the part the release notes don't put in bold:
lazy import no_such_module_xyz
print("survived the import statement") # <- this prints
no_such_module_xyz.anything
# ModuleNotFoundError: raised HERE, at first touch
An eager import fails at startup, in the traceback frame you're already looking at, before your program has done anything. A lazy import converts a startup error into a runtime error that can surface anywhere the name is first touched — potentially deep into a request, under a code path that only runs on Tuesdays. That's not an argument against the feature; it's the trade you're making, and it's the same trade every DI framework and plugin loader already makes on your behalf. Just make it with your eyes open: the 136 ms I saved is real, and so is the new failure mode.
frozendict: hashable, immutable — and not a dict
PEP 814's frozendict is the feature I expect to break the most code in the most surprising way, because of one line in my probe output:
frozendict({'a': 1, 'b': 2})
type: frozendict isinstance(fd, dict): False isinstance(fd, Mapping): True
fd['c'] = 3 -> TypeError: 'frozendict' object does not support item assignment
hash(fd) -> works; order-independent (== hash of {'b': 2, 'a': 1})
fd == {'a': 1, 'b': 2} -> True
fd | {'c': 3} -> a new frozendict
fd.setdefault(...) -> AttributeError; the mutating methods simply don't exist
usable as a dict key -> yes; pickle round-trips to frozendict
hash(frozendict({'x': []})) -> TypeError: unhashable type: 'list' (values must be hashable too)
It's a Mapping, deliberately not a dict subclass. Every isinstance(x, dict) check in the wild — config loaders, JSON-adjacent code, **kwargs plumbing — will treat a frozendict as "not a dict" even though it compares equal to one and iterates like one. That's arguably the correct design (a dict subclass that refuses mutation violates substitutability), but it means "just swap dict for frozendict" is not the drop-in it looks like. Where it shines is exactly where tuples shine next to lists: cache keys, shared default configs, anything you want to hash and stop worrying about.
sentinel: the repr is clean, the identity is not shared
PEP 661 adds a built-in sentinel type for the MISSING = object() pattern:
MISSING = sentinel('MISSING')
repr(MISSING) -> MISSING (finally, a readable repr)
sentinel('MISSING') is MISSING -> False (!)
pickle.loads(pickle.dumps(MISSING)) is MISSING -> True
Read that middle line twice. Two calls with the same name create different objects — identity only holds for the instance you actually hold (and across pickling, which resolves by name). Code that creates a sentinel in two modules with the same name and compares with is will fail, quietly, the way the old _sentinel = object() pattern failed when someone re-created it. The builtin improves the repr and the pickling; it does not give you a name-keyed singleton. Define once, import everywhere — the rule didn't change.
The rest, in one pass
-
Unpacking in comprehensions (PEP 798):
[*row for row in [[1, 2], [3, 4]]]evaluates to[1, 2, 3, 4]. Flattening a list of lists is finally a one-liner that doesn't needitertools.chainor a nested loop. -
UTF-8 by default (PEP 686): under
LC_ALL=C,open(...).encodingisutf-8andsys.flags.utf8_modeis 1. On Linux this changes little; the code it saves is the Windows code that silently used a locale encoding. The code it breaks is the code that unknowingly depended on that. - There's also the Tachyon sampling profiler (PEP 799) and a faster JIT in this release; I didn't benchmark either, so I won't pretend to have an opinion on their numbers.
Verdict
Lazy imports earned the headline: measured here, they remove real startup cost from the common "imports everything, uses one thing" shape of Python tooling, at the price of moving import errors from startup to first use. frozendict and sentinel are smaller, and both ship with exactly one sharp edge each — frozendict isn't a dict, sentinel isn't a singleton — and both edges are the kind you find in production rather than in the announcement post. Now you can find them here instead.
Sources: release manager Hugo van Kemenade's Python 3.15.0 release-day notes (October 9, 2026) and the release summary; PEPs 810, 814, 661, 798, 686. All measurements were run locally today on Python 3.15.1.
Top comments (0)