*Python 3.15 is the CPython release that adds lazy imports through the lazy keyword, the frozendict and sentinel builtins, UTF-8 as the default encoding, `and` unpacking in comprehensions and Tachyon, a sampling profiler. The official schedule sets the final release for 9 October 2026, a week later than planned. Below, every feature runs in Docker, with a startup measurement with and without lazy and the breakages I hit while upgrading. There is also a Spanish version of this Python 3.15 analysis.

Key takeaways

  • In a command-line tool (CLI) that imports requests, rich and PyYAML, marking six imports as lazy cut --version startup from 73.5 ms to 9.8 ms and the modules loaded from 342 to 62.
  • PYTHON_LAZY_IMPORTS=all makes every import lazy without code changes, but it breaks modules that register themselves on import: my plugin registry came back empty.
  • frozendict is an immutable, hashable dictionary that does not inherit from dict, so isinstance(x, dict) returns False.
  • With a Latin-1 locale, an open() call without encoding that read Muñoz on 3.14.7 raises UnicodeDecodeError on 3.15; PYTHONUTF8=0 restores the old behaviour.
  • Tachyon (python -m profiling.sampling) profiles a script or a running process at 1,000 samples per second by default and writes HTML flame graphs and heatmaps.
  • sre_compile, the --cgi mode of http.server and positional arguments to sqlite3.connect() beyond the first are gone.

What Python 3.15 brings and when it ships

Python 3.15 adds eight language and interpreter changes you will notice in everyday code. The Python 3.15 What’s New page[1] lists them. Hugo van Kemenade edits it and is also the release manager.

PEP 790, the Python 3.15 release schedule[2], closed the door to new features with beta 1 on 7 May 2026. The final release was set for Thursday 1 October, but a third release candidate, published on 2 October, moved it to Friday 9 October 2026. 3.15 gets bugfix releases every two months for two years and security fixes until roughly October 2031.

This table sums up the features I tested and what to watch for with each:

Feature PEP What it does What to watch
lazy import 810 Defers loading a module until first use Import errors surface when the name is used, not at startup
frozendict 814 Immutable, hashable dictionary Not a subclass of dict
sentinel 661 Named sentinel values with a readable repr Pickles only if importable by module and name
UTF-8 by default 686 open() without encoding uses UTF-8 Breaks files in the locale encoding
* and ** in comprehensions 798 [*l for l in lists] flattens lists New syntax: 3.14 raises SyntaxError
profiling and Tachyon 799 Sampling profiler, no code changes profile is deprecated and goes in 3.17
Frame pointers 831 Reliable native stacks for perf and eBPF Extensions with their own build system must keep the flags
Free-threaded stable ABI 803 abi3t extensions that work on no-GIL builds Build tools do not emit abi3t yet

How I tested Python 3.15 in Docker

Every test ran on 27 September 2026 with the official python:3.15.0rc2-slim image, the second of three release candidates, on Debian 13 (trixie). The machine is an aarch64 Linux box with 18 cores and no GPU, inside an OrbStack virtual machine. The Python 3.15.0rc2 page on python.org[3] is explicit: "only reviewed code changes which are clear bug fixes are allowed between this release candidate and the final release". That is why what follows holds for 3.15.0.

docker run --rm python:3.15.0rc2-slim python -VV
Python 3.15.0rc2 (main, Sep 19 2026, 01:05:41) [GCC 14.2.0]

For comparison I used python:3.14.7-slim-trixie. Once the final release is out, the python:3.15 and python:3.15-slim tags in the official Python repository on Docker Hub[4] point at 3.15.0; in your Dockerfiles, replace 3.15.0rc2-slim with 3.15-slim. The official image is built with --enable-optimizations and --with-lto, but without the JIT: sys._jit.is_available() returns False.

How lazy imports work

A lazy import creates a proxy object instead of loading the module. The real load happens on first use of the name. PEP 810, explicit lazy imports[5], is by Pablo Galindo Salgado, Germán Méndez Bravo, Thomas Wouters, Dino Viehland, Brittany Reynoso, Noah Kim and Tim Stumbaugh. lazy is a soft keyword: it only has that meaning in front of import or from, and lazy = "something" is still a valid assignment.

This is the header of the test CLI, a small report generator that reads YAML and prints a table with rich:

import argparse
lazy import json
lazy import sqlite3
lazy import requests
lazy import yaml
lazy from rich.console import Console
lazy from rich.table import Table

With --version the program prints its version and exits, so none of those six imports ever loads its module. You can see the proxy in globals(): before first use, type(globals()["json"]) returns <class 'lazy_import'>, and afterwards <class 'module'>. The type is exposed as types.LazyImportType.

What happens when a lazy import fails

If the module does not exist, the program starts without error and the exception appears on the line that uses the name. The traceback chains both locations, the import and the use:

arranca sin error
Traceback (most recent call last):
  File "/w/fallo.py", line 1, in <module>
    lazy import modulo_que_no_existe
ImportError: lazy import of 'modulo_que_no_existe' raised an exception
during resolution

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/w/fallo.py", line 6, in <module>
    modulo_que_no_existe.hacer()
ModuleNotFoundError: No module named 'modulo_que_no_existe'

lazy is only allowed at module level. Inside a function, Python 3.15 answers SyntaxError: lazy import not allowed inside functions, and it is not accepted in class bodies, try blocks or with import * either. If your code must keep running on 3.14, declare __lazy_modules__ = ["json"] at the top of the module. 3.15 then treats import json as lazy, while 3.14 ignores the variable and loads it straight away; I checked both images.

Global mode breaks imports with side effects

The PYTHON_LAZY_IMPORTS=all variable (or -X lazy_imports=all) makes every import lazy without touching a line. The risk lies in modules that do something when imported. In my test, plugin_csv.py appends itself to a plugin list on import, and app.py imports it without using it:

$ docker run --rm -v "$PWD":/w -w /w python:3.15.0rc2-slim python app.py
plugins: ['csv']
$ docker run --rm -e PYTHON_LAZY_IMPORTS=all -v "$PWD":/w -w /w \
    python:3.15.0rc2-slim python app.py
plugins: []

In global mode, plugin_csv never runs because nothing uses its name, and the registry stays empty with no error at all. If you want global mode, narrow it with sys.set_lazy_imports_filter(), which receives the importing and the imported module and decides which ones may be lazy.

How much startup lazy imports save

Marking the six imports as lazy cut --version startup by 87 %, from 73.5 ms to 9.8 ms median. I measured each case 50 times with a script that launches the interpreter through subprocess and times it with time.perf_counter(), inside the same container.

I only accepted batches where the host’s one-minute load average was below 3 at both start and end; in the four I publish it stayed between 1.09 and 1.15. I also report the child process CPU time, which under these conditions matches wall time.

Run time of each case, Python 3.15.0rc2 and 3.14.7 ms · lower is better
python -c pass (baseline), 3.15.0rc2
Median: 5.0 ms
p90: 5.5 ms
CPU: 4.9 ms
--version, regular imports, 3.15.0rc2
Median: 73.5 ms
p90: 78.7 ms
CPU: 73.3 ms
--version, six lazy imports, 3.15.0rc2
Median: 9.8 ms
p90: 10.7 ms
CPU: 9.8 ms
--version, PYTHON_LAZY_IMPORTS=all, 3.15.0rc2
Median: 9.5 ms
p90: 10.4 ms
CPU: 9.4 ms
Report from a YAML file, regular imports, 3.15.0rc2
Median: 71.3 ms
p90: 77.6 ms
CPU: 71.1 ms
Report from a YAML file, lazy imports, 3.15.0rc2
Median: 29.2 ms
p90: 30.9 ms
CPU: 29.0 ms
python -c pass (baseline), 3.14.7
Median: 4.4 ms
p90: 4.6 ms
CPU: 4.3 ms
--version, regular imports, 3.14.7
Median: 66.4 ms
p90: 70.6 ms
CPU: 66.3 ms
Report from a YAML file, regular imports, 3.14.7
Median: 68.6 ms
p90: 81.3 ms
CPU: 68.4 ms
Show the data table
Case and versionMedianp90CPU
python -c pass (baseline), 3.15.0rc25.0 ms5.5 ms4.9 ms
--version, regular imports, 3.15.0rc273.5 ms78.7 ms73.3 ms
--version, six lazy imports, 3.15.0rc29.8 ms10.7 ms9.8 ms
--version, PYTHON_LAZY_IMPORTS=all, 3.15.0rc29.5 ms10.4 ms9.4 ms
Report from a YAML file, regular imports, 3.15.0rc271.3 ms77.6 ms71.1 ms
Report from a YAML file, lazy imports, 3.15.0rc229.2 ms30.9 ms29.0 ms
python -c pass (baseline), 3.14.74.4 ms4.6 ms4.3 ms
--version, regular imports, 3.14.766.4 ms70.6 ms66.3 ms
Report from a YAML file, regular imports, 3.14.768.6 ms81.3 ms68.4 ms

50 runs per case in the same container, with the load average between 1.09 and 1.15.

The difference comes from the modules that never load. -X importtime writes one line per imported module plus a header, so the regular version loaded 342 modules and the lazy one 62:

python -X importtime informe_eager.py --version 2>imp_eager.txt
grep -c "import time:" imp_eager.txt
343
python -X importtime informe_lazy.py --version 2>imp_lazy.txt
grep -c "import time:" imp_lazy.txt
63

The most expensive import was requests, at 37.0 ms cumulative in that measurement. Even when generating the report, when yaml and rich do load, the lazy version took 29.2 ms against 71.3 ms, because requests and sqlite3 are never used.

PEP 810 anticipates this effect for command-line tools: "This can reduce startup time by 50-70% in practice". My 87 % beats that range because --version uses none of the modules; in a real command you save whatever your code path does not touch.

With regular imports, 3.15.0rc2 started a little slower than 3.14.7: 73.5 ms against 66.4 ms. A second batch, run in reverse order, gave 70.4 ms against 65.6 ms. Even the bare interpreter (python -c pass) took 5.0 ms against 4.4 ms.

frozendict: an immutable, hashable dictionary

frozendict is a builtin type that cannot change after creation. It works as a dictionary key when its keys and values are hashable. PEP 814, the frozendict builtin[6], was implemented by Victor Stinner and Donghee Na. This script checks what will matter most to you:

import json, copy
cfg = frozendict(host="db", port=5432)
try:
    cfg["port"] = 6432
except TypeError as e:
    print("TypeError:", e)
otro = frozendict(port=5432, host="db")
print(cfg == otro, hash(cfg) == hash(otro))
print({cfg: "principal"}[otro])
print(isinstance(cfg, dict), json.dumps(cfg))
print(copy.copy(cfg) is cfg)
print(cfg | {"port": 6432})

This was the output on 3.15.0rc2:

TypeError: 'frozendict' object does not support item assignment
True True
principal
False {"host": "db", "port": 5432}
True
frozendict({'host': 'db', 'port': 6432})

Equality ignores insertion order, so two frozendict objects with the same keys in a different order have the same hash. The | operator returns a new frozendict and leaves the original alone. json, copy, pickle and pprint accept it, but the False from isinstance(cfg, dict) is the trap: if your code validates with isinstance(x, dict), switch to isinstance(x, collections.abc.Mapping).

sentinel: named sentinel values

sentinel creates a unique object with a readable repr, to tell "nothing was passed" apart from None. It is PEP 661, sentinel values[7], written by Tal Einat and implemented by Jelle Zijlstra. Until now the usual pattern was MISSING = object(), which prints as <object object at 0x…>.

import copy, pickle
MISSING = sentinel("MISSING")

def get(d, k, default=MISSING):
    v = d.get(k, default)
    if v is MISSING:
        raise KeyError(k)
    return v

print(MISSING, copy.deepcopy(MISSING) is MISSING)
print(pickle.loads(pickle.dumps(MISSING)) is MISSING)
print(int | MISSING)
print(get({"a": None}, "a"))

The script printed MISSING True, True, int | MISSING and None. Deep copies and pickle keep the identity, so is MISSING still works after serialisation, and int | MISSING is valid as a type annotation.

Unpacking with * and ** in comprehensions

List, set and dict comprehensions now accept * and **, which replaces nested loops and itertools.chain(). PEP 798, unpacking in comprehensions[8], extends the PEP 448 syntax to this case:

listas = [[1, 2], [3, 4], [5]]
print([*l for l in listas])
print({*s for s in [{1, 2}, {2, 3}]})
defaults = {"retries": 3, "timeout": 10}
env = {"timeout": 30}
print({**d for d in (defaults, env)})

3.15.0rc2 printed [1, 2, 3, 4, 5], {1, 2, 3} and {'retries': 3, 'timeout': 30}: in dicts the last value wins. The same file on 3.14.7 fails before running with SyntaxError: iterable unpacking cannot be used in comprehension, so keep it out of libraries that still support older versions.

How to profile with Tachyon without touching the code

Tachyon is the sampling profiler in the new profiling package: instead of instrumenting every call, it reads the process stack at intervals. The profiling.sampling documentation[9] sets the default rate at 1 kHz and offers wall-clock, CPU, GIL and exception modes. cProfile stays as an alias of profiling.tracing, and the profile module is deprecated until its removal in 3.17.

I tested it with a script that serialises 20,000 rows to JSON and hashes them with SHA-256, 40 times over:

python -m profiling.sampling run --pstats lento.py
python -m profiling.sampling run --flamegraph -o flame.html lento.py
python -m profiling.sampling run --heatmap -o heat lento.py

The first command captured 1,109 samples in 1.11 s, at 997.64 samples per second. This is the output trimmed to three columns:

nsamples  sample%  filename:lineno(function)
 382/382     40.9  encoder.py:263(JSONEncoder.iterencode)
 166/166     17.8  lento.py:7(resumir)
 144/757     15.4  lento.py:4(serializar)

40.9 % of the samples landed inside the JSON encoder and 17.8 % in the hash computation. The --heatmap mode maps that data onto each source line (the screenshot comes from another run of the same script):

Tachyon per-line heatmap in Python 3.15: the line calling json.dumps collects 1,288 samples and the SHA-256 hash line 223, out of 1,538 samples in total.

For a process that is already running, python -m profiling.sampling attach -d 2 --pstats PID samples for two seconds and dump PID prints the current stack, which helps with hung processes. In my container both worked even as an unprivileged user, but only because this host has ptrace_scope set to 0. The documentation warns that on Linux you need root, CAP_SYS_PTRACE or a relaxed setting there, so in Docker add --cap-add SYS_PTRACE if it fails. The profiler and the target must run the same Python minor version.

What UTF-8 as the default encoding breaks

Python 3.15 uses UTF-8 for open() and other text I/O when you pass no encoding, regardless of the system locale. This is PEP 686, making UTF-8 mode the default[10], written by Inada Naoki. In the official Docker image you will not notice: it sets no locale, and under the C locale 3.14.7 already turned UTF-8 mode on (sys.flags.utf8_mode was 1). The change bites on systems with legacy locales.

To reproduce it, I generated the es_ES ISO-8859-1 locale in both images and read a CSV exported in Latin-1 with open("clientes.csv"), without encoding:

3.14.7 utf8_mode: 0 locale: ISO-8859-1
'id;nombre\n1;Mu\xf1oz\n'

3.15.0rc2 utf8_mode: 1 locale: ISO-8859-1
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf1 in position 14:
invalid continuation byte

The proper fix is to declare the encoding: with open("clientes.csv", encoding="latin-1"), 3.15 read Muñoz again. As a stopgap, PYTHONUTF8=0 brought back the 3.14 behaviour. To find these calls before migrating, run your tests on 3.14 with -X warn_default_encoding: in my case it flagged the exact line with an EncodingWarning.

Frame pointers and the JIT: what changes underneath

CPython 3.15 is built by default with -fno-omit-frame-pointer and -mno-omit-leaf-frame-pointer, per PEP 831 on frame pointers[11]. In the official image I checked that both flags appear in sysconfig.get_config_var("CFLAGS"). With them, tools like perf or eBPF-based profilers rebuild the native stack without unwind tables, a problem we covered in continuous profiling with eBPF in production.

The experimental JIT improves too. The What’s New page quotes a 7-8 % geometric-mean gain in pyperformance over the standard interpreter on x86-64 Linux, and 11-12 % on AArch64 macOS. I did not measure it: the official Docker image does not include it, and you would have to build CPython with LLVM 21. The GIL stays on in the standard build; the no-GIL option is the one we explained in Python 3.13 with optional GIL, and 3.15 adds the abi3t stable ABI for its extensions.

What is removed and what you hit when upgrading

3.15 removes APIs that 3.13 and 3.14 already marked as deprecated. I ran the same script on both versions; on 3.14.7 each case was a DeprecationWarning, and on 3.15.0rc2 it was this:

sre_compile: ModuleNotFoundError: No module named 'sre_compile'
sqlite3 posicional: TypeError: connect() takes at most 1 positional
arguments (2 given)
strptime %d sin año: ValueError: Day of month directive '%d' may not be
used without a year directive.
import profile: DeprecationWarning The profile module is deprecated and
will be removed in Python 3.17.

python -m http.server --cgi now fails too, with unrecognized arguments: --cgi, because CGIHTTPRequestHandler is gone. Other removals on the official list are typing.no_type_check_decorator, codeobject.co_lnotab and the load_module() method of importlib loaders. On top of that, re.prefixmatch() arrives as an explicit name for re.match(), which stays.

Error messages keep improving, in line with what we covered in Python 3.12 and its errors that finally help. [1, 2].push(3) now answers Did you mean '.append'? and 'hola'.toUpperCase() answers Did you mean '.upper'?; 3.14.7 gave no hint.

Binary wheels for cp315

On 27 September 2026 I used pip download --only-binary=:all: to check which compiled packages had cp315 wheels for aarch64. I found them for these:

  • numpy 2.5.3 and pandas 3.0.6
  • pydantic-core 2.49.0 and orjson 3.12.0
  • lxml 6.1.3, pillow 12.3.0 and psycopg-binary 3.3.6
  • cryptography 50.0.1, with an abi3 wheel that works on any CPython from 3.11 on

According to python.org, wheels built against the release candidates "will work with future versions of Python 3.15", so they do not need a rebuild for the final release.

How to upgrade a Docker image to Python 3.15

The upgrade comes down to changing the base tag and running your tests with warnings turned into errors. This is the Dockerfile I used for the tests with dependencies:

FROM python:3.15.0rc2-slim
RUN pip install --no-cache-dir --root-user-action=ignore \
    requests rich pyyaml

Before you switch the tag to python:3.15-slim in production, follow these steps in order:

  1. Run your tests on 3.14 with -W error::DeprecationWarning -X warn_default_encoding to see what 3.15 removes and which open() calls lack an encoding.
  2. Add an explicit encoding= to every open() that reads files your application does not write.
  3. Check with pip download --only-binary=:all: that your compiled dependencies have cp315 wheels.
  4. Switch the base tag and rerun the tests; if you validate configuration with isinstance(x, dict), check whether a frozendict can reach it.
  5. Measure startup with python -X importtime and mark as lazy the imports your most common path does not use.

Frequently asked questions

When does Python 3.15 ship and how long is it supported?

PEP 790 sets 3.15.0 final for 9 October 2026; the original date was 1 October. It gets bugfix releases every two months for two years and security fixes until roughly October 2031.

Do I have to rewrite my imports to use lazy imports?

No. You can mark only the expensive imports with lazy, use __lazy_modules__ if you keep 3.14 compatibility, or turn on global mode with PYTHON_LAZY_IMPORTS=all. Global mode skips modules that register themselves on import, so test it on your code first.

Is Python 3.15 faster than 3.14 without changing anything?

Not in my test. With regular imports, 3.15.0rc2 started my CLI a little slower than 3.14.7: 73.5 ms against 66.4 ms median. The big gain comes from marking imports as lazy. The JIT promises 7-8 % on x86-64, but the official Docker image does not ship it.

Conclusion

Python 3.15 shows mostly in startup time and ergonomics. lazy turns into one word what used to be imports hidden inside functions, and frozendict and sentinel replace home-made patterns. The real breakages are few and predictable: files read without encoding, modules removed after two releases of warnings and sqlite3 parameters that become keyword-only. Start by running your tests on 3.14 with -X warn_default_encoding, then switch your image tag to python:3.15-slim.

Sources: [1] What’s New in Python 3.15[1], [2] PEP 790: Python 3.15 release schedule[2], [3] PEP 810: explicit lazy imports[5], [4] PEP 814: the frozendict builtin type[6], [5] PEP 661: sentinel values[7], [6] profiling.sampling (Tachyon) documentation[9], [7] PEP 686: UTF-8 mode by default[10], [8] PEP 798: unpacking in comprehensions[8], [9] PEP 831: frame pointers by default[11], [10] Python 3.15.0rc2 page on python.org[3], [11] Official Python image on Docker Hub[4], [12] Python 3.15.0rc3 page on python.org[12].

Sources

  1. Python 3.15 What’s New page
  2. PEP 790, the Python 3.15 release schedule
  3. Python 3.15.0rc2 page on python.org
  4. official Python repository on Docker Hub
  5. PEP 810, explicit lazy imports
  6. PEP 814, the frozendict builtin
  7. PEP 661, sentinel values
  8. PEP 798, unpacking in comprehensions
  9. profiling.sampling documentation
  10. PEP 686, making UTF-8 mode the default
  11. PEP 831 on frame pointers
  12. Python 3.15.0rc3 page on python.org