Skip to content
Developer tooling

Python 3.15 is out: lazy imports, UTF-8 by default, and what to test before you upgrade

What Python 3.15 changes for working code: how lazy imports work and fail, the UTF-8 default, frozendict, Tachyon, and an upgrade checklist.

K9 min read
Highlighted code

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):

Terminal
$ 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):

cli.pyPython
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 here

The 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.

lazy import jsonmodule scopeLazyImportTypeplaceholder globalFirst read of jsonImport systemsys.path at that momentjson modulerebinds the globalbinds namereads namereify once, under the import lock
Nothing is loaded until a name is read; after that the global is the real module.

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).

Lazy imports halve start-up when the deferred modules go unusedStart-up time in milliseconds, best of 10, lower is better
Eager importsLazy imports
0  ms20  ms40  ms60  ms80  ms--help (nothing deferred is used)--load-all (everything is used)

Source: Real Python, Python 3.15 Preview: Lazy Imports (3.15.0rc1, author's machine)

Lazy imports halve start-up when the deferred modules go unused
Eager importsLazy imports
--help (nothing deferred is used)68  ms32  ms
--load-all (everything is used)60  ms59  ms

To see which imports are worth deferring, sort the cumulative times that -X importtime writes to stderr:

Terminal
$ python -X importtime cli.py --help 2>&1 >/dev/null \    | grep -E '\| [^ ]' | sort -t'|' -k2 -rn | head -6

Where 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):

mylib/__init__.pyPython
__lazy_modules__ = ["json", "pathlib"] import json     # lazy on 3.15, eager on 3.14import os       # eager everywhere

Assign 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:

app/__main__.pyPython
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 not

The 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:

Terminal
$ python -X warn_default_encoding -W error::EncodingWarning -m pytest

Each 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:

export.pyDiff
- 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):

Python
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 key

It 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):

Python
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):

Terminal
$ python -m profiling.sampling run --flamegraph --browser element_counts.py

For 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:

ChangeReported gainSource
Windows x86-64 binaries, pyperformance15–20%Astral
Linux and macOS, pyperformance3–5%Astral
JIT, x86-64 Linux, over the standard interpreter7–8% geometric meanpython.org
JIT, AArch64 macOS, over the tail-calling interpreter11–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() raises MetadataNotFound when 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.typecodes is a tuple, not a str.
  • argparse.ArgumentParser suggests close matches on a bad choice by default (suggest_on_error=True).
  • Help text, http.server, sqlite3 and other tools print coloured output by default. Scripts that parse that output should set the colour controls documented in What's New.
  • import lines in .pth files are deprecated, and ignored when a matching .start file exists.
  • re.match() is soft-deprecated in favour of re.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.15 and run the full test suite against it.
  • Run your tests on your current Python with -X warn_default_encoding -W error::EncodingWarning, and add encoding= 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 a frozendict.
  • Measure start-up with -X importtime before adding lazy, 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.

Found this useful? Share itXLinkedIn