Articles › Programming

Python 3.15: lazy imports, frozendict, and UTF-8 by default on Windows

Python 3.15 is out. Lazy imports cut CLI startup time, frozendict works as a dict key, and open() now reads and writes UTF-8 everywhere, Windows included.

Intermediate 8 min read October 23, 2026

What you will learn
  • lazy import json loads the module on first use of the name, so a script that imports heavy modules it rarely needs starts much faster
  • A failing lazy import raises where the name is used, not on the import line, so tests must exercise the code path that uses it
  • frozendict is a new immutable, hashable builtin that works directly as a dict key or cache key
  • 3.15 uses UTF-8 as the default encoding everywhere; code that relied on a Windows locale code page must pass encoding="locale" or convert its files

Python 3.15 was released on 9 October 2026, with 3.15.1 following on 21 October. Three changes touch everyday work: lazy imports that make programs start faster, the long-requested frozendict, and UTF-8 as the default encoding, which closes a class of non-ASCII file bugs that Windows users have hit for years. This article runs all three with real code and shows how to move existing code across without breaking it.

Tested on Windows 11 with Python 3.15.1 (installed via uv 0.13.5), compared against Python 3.12.10.

Install it and try

uv self update          # older uv does not know the final 3.15 (installed uv with pip? use pip install -U uv)
uv python install 3.15
uv run --python 3.15 python -VV

First trap: the uv 0.10.9 already on the machine only offered 3.15.0a6, an alpha. Update uv first and 3.15.1 appears. With pyenv or the python.org installer, check the version string is not an alpha or beta.

What a lazy import is

A normal import json finds, loads and executes the module the moment that line runs, even if this particular run never touches json. PEP 810 adds a lazy prefix: Python binds a lightweight proxy to the name and does the real import on first use.

import sys
lazy import json
lazy from decimal import Decimal

print("json loaded?", "json" in sys.modules)   # False
print(json.dumps({"a": 1}))                     # json is imported here
print("json loaded?", "json" in sys.modules)   # True
print(Decimal("0.1") + Decimal("0.2"))          # 0.3

On 3.15.1 the first check prints False and the second True, after json.dumps ran. The code that uses the names does not change at all.

lazy is a soft keyword: it only means something in front of import/from. An existing variable called lazy still works.

Worked example: how much faster does a CLI start?

The clearest win is a CLI that imports heavy modules at the top but only needs a few per command. The test script imports asyncio, email.message, http.client, decimal, json, sqlite3 and xml.etree.ElementTree, then handles --version, which uses none of them. Wall time from launch to exit, median of 30 runs:

VariantMedian time
python -c pass (bare interpreter)27.6 ms
all 7 imported normally234-245 ms
lazy in front of all 728-29 ms
normal imports, run with -X lazy_imports=all27-29 ms

Step by step:

  1. Time spent on imports = 240 − 27.6 ≈ 212 ms per invocation
  2. With lazy, the overhead drops to under 2 ms (29 − 27.6)
  3. If a git hook or script calls this CLI 500 times a day, that is 212 ms × 500 = 106 seconds a day

Absolute numbers depend on the machine and filesystem, so measure your own. To see which module costs what, run python -X importtime app.py; in this test its report shrank from 194 lines to 30.

Lazy imports do not make a module load faster; they move the load to first use. If every path uses every module, the total time is the same.

Bare Python27.6 msNormal imports~240 msLazy imports~29 msmedian of 30 runs, Python 3.15.1
A CLI that uses none of its imports starts almost as fast as bare Python

Restrictions, and errors that move

lazy is only allowed at module level. What I got when trying otherwise:

lazy import inside a function   → SyntaxError: lazy import not allowed inside functions
lazy import inside try/except   → SyntaxError: lazy import not allowed inside try/except blocks

lazy from x import * and lazy from __future__ import ... are rejected too.

The thing to watch most is where errors surface. If a module is missing, the program starts fine and fails at first use:

lazy import not_installed_pkg
print("startup ok")              # this prints
def use():
    return not_installed_pkg.run()
use()
startup ok
ImportError: lazy import of 'not_installed_pkg' raised an exception during resolution
...
ModuleNotFoundError: No module named 'not_installed_pkg'

The traceback names both the import line and the line that used it, so tracking it down is quick. But if the module is only used by a rarely run command, the bug shows up in production rather than at deploy. Make sure tests exercise the path that uses each lazy module, or add a smoke test that plainly imports every module once.

Supporting several Python versions

A file containing lazy import is a SyntaxError on 3.14. For libraries that must support older versions, use the __lazy_modules__ variable instead:

__lazy_modules__ = ["json", "decimal"]
import sys
import json      # 3.15: lazy · 3.12: eager, as before
import decimal
print("json" in sys.modules)

It prints False on 3.15.1 and True on 3.12.10. One file works on both.

Two whole-program switches exist as well: -X lazy_imports=all or the PYTHON_LAZY_IMPORTS=all environment variable make every import lazy, and sys.set_lazy_imports_filter() picks which modules qualify. all is handy for measuring, but risky with modules that rely on import-time side effects, such as plugins that register themselves when loaded. In production, opt in module by module.

frozendict

frozendict is a new builtin (PEP 814). It cannot be modified after creation, and it is hashable as long as all its keys and values are, so it can be a dict key or a set member.

a = frozendict(x=1, y=2)
a == frozendict(y=2, x=1)                     # True (order does not affect equality)
hash(a) == hash(frozendict(y=2, x=1))         # True
a["z"] = 3       # TypeError: 'frozendict' object does not support item assignment
a | {"z": 3}     # frozendict({'x': 1, 'y': 2, 'z': 3}), a new object; a is unchanged

cache = {frozendict(city="BKK", unit="C"): 33}
cache[frozendict(unit="C", city="BKK")]       # 33

Good fits: cache keys built from several parameters, configuration nobody should change at runtime, and mapping defaults for function arguments (no mutable-default trap).

One catch: frozendict is not a subclass of dict. Code that checks isinstance(x, dict) gets False; check collections.abc.Mapping or (dict, frozendict) instead. json and pickle already support it.

UTF-8 by default: the Windows text-file bug

Before 3.15, open("data.txt") without an encoding used the machine’s locale encoding. Linux and macOS are UTF-8 already, so nobody noticed there. Windows uses a legacy code page per language: cp1252 for Western European setups, cp874 for Thai, and so on. The same file reads fine on one machine and breaks on another.

Take the Thai word “สวัสดี” (6 characters), byte by byte:

EncodingSizeBytes
cp8746 bytesca c7 d1 ca b4 d5
UTF-818 bytes3 per character, starting e0 b8 ...
  • Written on a Thai-locale Windows box (cp874), then read as UTF-8 on a server: UnicodeDecodeError: 'utf-8' codec can't decode byte 0xca in position 0
  • The other way round, a UTF-8 file read as cp874 raises nothing but returns เธชเธงเธฑเธชเธ”เธต. That repeating “เธ” pattern is the tell-tale sign of UTF-8 decoded as cp874

The test machine runs a cp1252 locale, which fails even harder: Python 3.12 could not write the Thai text at all and raised UnicodeEncodeError: 'charmap' codec can't encode characters. Python 3.15.1 wrote correct UTF-8 with no code change, and sys.flags.utf8_mode is 1.

PEP 686 turns UTF-8 mode on by default. It covers open(), stdin/stdout/stderr, and pipes, including subprocess with text=True. Piping print("สวัสดี") crashed with UnicodeEncodeError on 3.12; on 3.15.1 it came out as correct UTF-8 bytes. This is separate from the Windows time-zone trap that needs tzdata, which still applies.

Moving to 3.15 without breaking things

The code that breaks is code that deliberately read a legacy code page, such as a CSV exported by an old Windows accounting program. On 3.15 it raises UnicodeDecodeError straight away. Find and fix it:

# on 3.10+, find every open() without an explicit encoding
python -X warn_default_encoding app.py
# <string>:1: EncodingWarning: 'encoding' argument not specified
open("export.csv", encoding="cp874")    # a file you know comes from the old system
open("notes.txt", encoding="locale")    # you really do want the machine's encoding
open("data.json", encoding="utf-8")     # everything else

If you must upgrade before the fixes are done, set PYTHONUTF8=0 temporarily. To learn the machine’s real encoding, use locale.getencoding() (it ignores UTF-8 mode), not locale.getpreferredencoding().

Pass encoding= every time you open a text file and the code behaves the same on every machine and every version, the point made in works on my machine.

Common mistakes

  • Using lazy in a file that must also run on 3.14. The whole file fails with SyntaxError; use __lazy_modules__
  • No test covering the path that uses a lazy module. The ModuleNotFoundError appears when a user opens that menu, not at deploy
  • Turning on -X lazy_imports=all for a whole project. Modules that register themselves at import time silently vanish
  • isinstance(x, dict) against a frozendict. It is False; use Mapping
  • Assuming the UTF-8 default fixes everything. Legacy code-page files still need an explicit encoding, and code running on 3.14 or older still gets the locale encoding
  • Libraries without 3.15 wheels yet. The release is weeks old; try pip install in a separate venv before switching a server

Sources

FAQ

Does code with lazy import run on Python 3.14?

No. lazy is new syntax, so 3.14 raises SyntaxError for the whole file. If you must support several versions, use the __lazy_modules__ variable instead. 3.15 treats the listed imports as lazy, and older versions see an ordinary variable and import eagerly as before.

With UTF-8 as the default, do I still need encoding="utf-8"?

Yes, if the code must also run on 3.14 or older. The Python docs themselves recommend passing encoding explicitly so behaviour is the same on every version. If you only run 3.15 and later, you can leave it out.

Can I switch UTF-8 mode off again?

Yes. Set PYTHONUTF8=0 or run python -X utf8=0. Treat it as a temporary escape hatch while you fix the code, not a permanent setting.

Commands and settings were tested on sample systems. Try them on a test machine before production.