Python 3.15.0 was released on 9 October 2026 (python.org). The headline is explicit lazy imports, a lazy keyword that defers loading a module until a name from it is used. Two changes matter more for an upgrade than for a changelog: UTF-8 is now the default text encoding everywhere, Windows included, and a missing module now fails at first use instead of at the import line if you make it lazy.
This article shows how lazy imports work and where they break, how to find the code that the UTF-8 switch will change, what the new frozendict, sentinel and Tachyon profiler give you, and a checklist for moving a service from 3.14.
Install it next to the version you run
3.15 gets bugfix releases every two months and security fixes until about October 2031 (PEP 790). The quickest way to try it without touching the interpreter your service uses is uv, which can install 3.15 as soon as it ships (Astral):
$ uv self update$ uv python upgrade 3.15$ uv run --python=3.15 python -c "import sys; print(sys.version)"Run your test suite against it before you read anything else here. The rest of this article explains the failures you are most likely to see.
Lazy imports move the cost of an import to the first use
A lazy import binds the name to a placeholder object. The module is loaded the first time the name is read, and the global is then replaced by the real module (What's New in Python 3.15):
lazy import jsonlazy from pathlib import Path print("Starting up...") # json and pathlib are not imported yet data = json.loads('{"key": "value"}') # json is imported herep = Path(".") # pathlib is imported hereThe placeholder is a types.LazyImportType. Exactly one thread performs the import and atomically rebinds the module global, using the import system's existing locks (PEP 810). The import runs with sys.path and sys.meta_path as they are at that moment, not as they were when the lazy import line ran.
Not everything reads the name. Calling globals() or reading a module's __dict__ hands back the placeholder, and dir() does not load it either. A debugger hover or a repr() does (PEP 810, Real Python).
What it saves
The gain depends on how many imports your program never touches on a given path. A command-line tool's --help is the best case: Real Python's example CLI loaded 222 modules eagerly and 76 lazily, and started in about half the time. When every deferred module is used anyway, the two versions take the same time, so deferral costs almost nothing (Real Python).
Source: Real Python, Python 3.15 Preview: Lazy Imports (3.15.0rc1, author's machine)
| Eager imports | Lazy imports | |
|---|---|---|
| --help (nothing deferred is used) | 68 ms | 32 ms |
| --load-all (everything is used) | 60 ms | 59 ms |
To see which imports are worth deferring, sort the cumulative times that -X importtime writes to stderr:
$ python -X importtime cli.py --help 2>&1 >/dev/null \ | grep -E '\| [^ ]' | sort -t'|' -k2 -rn | head -6Where lazy imports break
Errors move. If json is misspelt or not installed, the lazy import line succeeds and the failure arrives at first use, with a chained traceback that points at both places. Syntax errors in the imported module are deferred too (PEP 810). A broken optional dependency that used to stop a deploy at start-up can now surface on the first request that touches it.
Side effects stop running. A module whose only job is to register itself, through a decorator, a metaclass or __init_subclass__, never runs if nothing reads its name. Real Python's plugin example printed an empty list of formats with no error at all. The PEP's advice is to replace import-time registration with an explicit discovery function.
Placement is restricted. lazy is only allowed at module scope. Inside a function, a class body or a try/except/finally block it is a SyntaxError, and so are lazy from module import * and lazy from __future__ import ... (What's New). On 3.14 and earlier the keyword is a SyntaxError everywhere.
Submodules are not covered by the parent. PEP 810 recommends importing a submodule by its own name (import foo.bar) rather than relying on import foo to make foo.bar available.
Keep supporting 3.14 with __lazy_modules__
A library that still supports older Pythons cannot use the keyword. It can list module names in __lazy_modules__ instead; 3.15 treats the matching imports as lazy and older versions ignore the variable (What's New):
__lazy_modules__ = ["json", "pathlib"] import json # lazy on 3.15, eager on 3.14import os # eager everywhereAssign it before the imports it covers and use fully qualified names; otherwise it is ignored without a warning (Real Python).
Turn it on for a whole application, carefully
-X lazy_imports=all or PYTHON_LAZY_IMPORTS=all makes every import lazy, third-party code included. That is where side-effect imports bite hardest, so scope it with a filter that receives the importing module, the imported module and the fromlist:
import sys def only_our_code(importing, imported, fromlist): return imported.startswith("myapp.") sys.set_lazy_imports_filter(only_our_code)sys.set_lazy_imports("all") import myapp.slow_module # lazy: matches the filterimport json # eager: does notThe filter only applies to imports that run after it is installed, so set it first.
UTF-8 is now the default encoding, on Windows too
From 3.15, UTF-8 mode is on by default (PEP 686). open("data.txt") with no encoding argument reads and writes UTF-8, and so do the standard streams and pipes. Most Linux and macOS systems already used a UTF-8 locale, so they see little change. On Windows, where the default was the ANSI code page, a file written by 3.14 with open(path, "w") can now be read back differently.
The PEP lists the failure modes as UnicodeError, mojibake, or silent data corruption. The last one is the one to worry about: a CSV export that used to be cp1252 is now UTF-8, and the system that imports it may not notice.
Find the calls that relied on the default
Python has been able to warn about a missing encoding argument since 3.10 (PEP 597). Turn the warning into an error and run your tests on the version you use today:
$ python -X warn_default_encoding -W error::EncodingWarning -m pytestEach failure is a call to fix. Pass encoding="utf-8" where you mean UTF-8, and encoding="locale" where you really need the old behaviour:
- with open(path, "w") as f:+ with open(path, "w", encoding="utf-8", newline="") as f: writer = csv.writer(f)locale.getpreferredencoding(False) follows UTF-8 mode, so code that used it to discover the system encoding now gets "utf-8". Use locale.getencoding(), available since 3.11, which ignores UTF-8 mode. For subprocess, PEP 686 suggests replacing text=True with an explicit encoding=.
If you need time, PYTHONUTF8=0 or -X utf8=0 turns UTF-8 mode off. Treat it as a stopgap: it hides the calls you still need to fix.
Smaller additions you will use
frozendict is a built-in
frozendict is immutable and hashable when its keys and values are. It keeps insertion order but compares without regard to it (What's New):
a = frozendict(x=1, y=2)b = frozendict(y=2, x=1)assert a == b and hash(a) == hash(b) cache = {a: "computed"} # usable as a dict keyIt is not a subclass of dict, so isinstance(arg, dict) returns False for it. Code that type-checks its arguments should test collections.abc.Mapping instead, or (dict, frozendict). json, pickle, copy and pprint support it.
Unpacking in comprehensions
* and ** now work inside comprehensions and generator expressions (PEP 798):
lists = [[1, 2], [3, 4], [5]]flat = [*L for L in lists] # [1, 2, 3, 4, 5] dicts = [{"a": 1}, {"b": 2}, {"a": 3}]merged = {**d for d in dicts} # {'a': 3, 'b': 2}A sentinel type
sentinel creates unique marker values with a readable repr that survive copy and can be pickled when importable by module and name (What's New). It replaces the _MISSING = object() idiom for "no value passed" defaults.
Tachyon samples a running process without changing it
The new profiling package holds two profilers: profiling.tracing, which is the old cProfile (still importable by that name), and profiling.sampling, called Tachyon. Tachyon samples at up to 1,000,000 Hz, can attach to a running process by PID, and has wall-clock, CPU, GIL and exception modes. It writes pstats, collapsed stacks, flame graphs, Firefox Profiler files and heat maps, and has a live terminal view (What's New).
Astral's example profiles a script and opens a flame graph in the browser (Astral):
$ python -m profiling.sampling run --flamegraph --browser element_counts.pyFor a production incident, attach mode is the feature to learn: you get a profile of the process that is misbehaving now, without restarting it. The profile module is deprecated and goes away in 3.17.
How much faster it is depends on your platform
The interpreter changes are uneven across platforms. The official Windows 64-bit binaries now use the tail-calling interpreter, and the experimental JIT has been reworked:
| Change | Reported gain | Source |
|---|---|---|
Windows x86-64 binaries, pyperformance | 15–20% | Astral |
Linux and macOS, pyperformance | 3–5% | Astral |
| JIT, x86-64 Linux, over the standard interpreter | 7–8% geometric mean | python.org |
| JIT, AArch64 macOS, over the tail-calling interpreter | 11–12% | python.org |
The JIT is still marked experimental. Frame pointers are now on by default, so perf and eBPF tools can unwind Python stacks without a special build.
Behaviour changes that break quietly
These are in the 3.15 changelog and are easy to miss in a large diff (What's New):
importlib.metadata.metadata()raisesMetadataNotFoundwhen the metadata file is missing, instead of returning an empty object. Version-sniffing code that tolerated missing packages will now raise.__cached__is no longer set or read. Use__spec__.cached.array.typecodesis atuple, not astr.argparse.ArgumentParsersuggests close matches on a bad choice by default (suggest_on_error=True).- Help text,
http.server,sqlite3and other tools print coloured output by default. Scripts that parse that output should set the colour controls documented in What's New. importlines in.pthfiles are deprecated, and ignored when a matching.startfile exists.re.match()is soft-deprecated in favour ofre.prefixmatch(). Nothing breaks yet; linters will start to flag it.
What to do before you move a service to 3.15
- Install 3.15 with
uv python upgrade 3.15and run the full test suite against it. - Run your tests on your current Python with
-X warn_default_encoding -W error::EncodingWarning, and addencoding=to every call it flags. - Check every file you write that another system reads; pin its encoding explicitly.
- Search for
isinstance(..., dict)in code that might now receive afrozendict. - Measure start-up with
-X importtimebefore addinglazy, and never make a self-registering module lazy. - If you try
PYTHON_LAZY_IMPORTS=all, add a filter and test the paths that load optional dependencies, since their errors now arrive late. - Wait for your C extensions to publish 3.15 wheels before you deploy, and read the "Removed" section of What's New for APIs you use.
The UTF-8 switch is the change most likely to corrupt data without raising an error. Fix it first; lazy imports can wait until you have measured something worth deferring.