← All Posts
Pythondeveloper toolsopen source

Python 3.15 Released: Lazy Imports, UTF-8 and a Faster JIT

October 18, 2026

Python 3.15 changes how you import modules, read files and profile production code. Released on October 9, 2026, it adds explicit lazy imports, makes UTF-8 the default encoding, introduces frozendict and sentinel built-ins, and ships a sampling profiler called Tachyon alongside an upgraded experimental JIT. Most of it is opt-in, but the UTF-8 change can alter behavior in code you already have.

Key Takeaways

  • Lazy imports (PEP 810) use a new lazy soft keyword to defer loading a module until its name is first used. They only work at module scope.
  • Calling open() without an encoding now uses UTF-8 regardless of system settings (PEP 686). Pass encoding explicitly to stay portable across versions.
  • frozendict and sentinel are new built-ins, and comprehensions now accept * and ** unpacking.
  • Tachyon (profiling.sampling) attaches to a running process by PID without a restart, and CPython builds now keep frame pointers by default.
  • The JIT is still experimental. Python.org reports a 7-8% geometric-mean gain on x86-64 Linux over the standard interpreter.

How do lazy imports work in Python 3.15?

Writing lazy import json binds the name to a lightweight proxy instead of loading the module. The real import runs the first time you touch the name. It works with from-imports too:

lazy import json
lazy from pathlib import Path

print("Starting up...") # neither module is fully loaded yet

data = json.loads('{"key": "value"}') # json loads here
p = Path(".") # pathlib loads here

If a lazily imported module fails to load, the exception is raised at first use rather than at import time, and the traceback points to both the access and the original import statement. If you currently rely on a bad dependency failing at startup, that timing changes.

You can also change the default without editing source. The -X lazy_imports option and the PYTHON_LAZY_IMPORTS environment variable accept all or normal, where normal is the default and respects the keyword. For finer control, sys.set_lazy_imports_filter() takes a callable, so you can make only your own packages lazy and keep third-party imports eager:

import sys

def myapp_filter(importing, imported, fromlist):
return imported.startswith("myapp.")

sys.set_lazy_imports_filter(myapp_filter)
sys.set_lazy_imports("all")

There are limits. lazy is only allowed at module scope: inside a function, a class body or a try/except/finally block it raises a SyntaxError, and star imports and __future__ imports can't be lazy. If you still support older Pythons, a module-level __lazy_modules__ list makes plain import statements for those modules lazy on 3.15 and later:

__lazy_modules__ = ["json", "pathlib"]

import json # lazy on 3.15+
import os # still eager

What breaks when UTF-8 becomes the default encoding?

Python now defaults to UTF-8 for text I/O no matter what the system locale says, so open('notes.txt') reads UTF-8 even on a machine configured for another encoding. This only applies when you don't pass an encoding argument. Code that quietly depended on the locale encoding will behave differently, and that is most likely on machines whose locale isn't UTF-8.

You have three ways to handle it. Pass encoding explicitly everywhere, which is what the docs recommend for compatibility across versions. Use encoding='locale' where you really want the system encoding (supported since Python 3.10). Or turn UTF-8 mode off with PYTHONUTF8=0 or -X utf8=0 to keep the old behavior. The docs also point to an opt-in encoding warning that helps you find affected code.

What do frozendict and sentinel add?

frozendict is a new immutable built-in. It doesn't subclass dict, it's hashable as long as every key and value is hashable, and it keeps insertion order while ignoring order when comparing:

>>> a = frozendict(x=1, y=2)
>>> b = frozendict(y=2, x=1)
>>> hash(a) == hash(b)
True
>>> a == b
True

Because it isn't a dict subclass, isinstance(arg, dict) checks will reject it. The docs suggest isinstance(arg, (dict, frozendict)) or checking against collections.abc.Mapping instead. json.load() and json.loads() also gained an array_hook parameter, and passing frozendict as object_pairs_hook with tuple as array_hook gives you a deeply immutable result.

The new sentinel built-in creates unique marker values with a concise representation. Sentinels keep their identity when copied, work in type expressions with the | operator, and can be pickled when they are importable by module and name.

What else changed in the language and standard library?

List, set and dict comprehensions and generator expressions now accept * and ** unpacking (PEP 798). It's a flat alternative to nested comprehensions and itertools.chain:

>>> lists = [[1, 2], [3, 4], [5]]
>>> [*L for L in lists]
[1, 2, 3, 4, 5]

>>> dicts = [{'a': 1}, {'b': 2}, {'a': 3}]
>>> {**d for d in dicts}
{'a': 3, 'b': 2}

Smaller changes worth knowing: asyncio.TaskGroup gained a cancel() method for ending a group early without the old extra-task workaround, and AttributeError messages now suggest the Python equivalent when you use a method name from another language, such as .append when you call .push on a list.

What is Tachyon, the new sampling profiler?

Tachyon lives in the new profiling.sampling module (PEP 799). Instead of instrumenting every function call, it periodically captures stack traces from a running process, so you can attach by PID without changing code or restarting. The docs claim sampling rates up to 1,000,000 Hz, which is the project's own claim, so benchmark the overhead on your workload.

It can also run a script or module under the profiler and dump a snapshot of every thread's stack, which helps with hung processes. Modes measure wall-clock time, CPU time, GIL-holding time or exception handling time. Output formats include pstats, collapsed stacks for flame graph tools, a self-contained HTML flame graph, Gecko format for Firefox Profiler and a line-level heatmap. The old deterministic tracer moved to profiling.tracing, cProfile stays as an alias, and the pure-Python profile module is deprecated and slated for removal in Python 3.17.

Related: CPython builds now keep frame pointers by default (PEP 831), which makes native stack unwinding faster and more reliable for system profilers, debuggers and eBPF-based tools. The docs warn that a single native component built without frame pointers can break unwinding for the whole process, so build backends that read Python's sysconfig values should preserve those flags.

How much faster is the JIT in Python 3.15?

The JIT is still experimental. Python.org reports a 7-8% geometric-mean improvement over the standard interpreter on x86-64 Linux, and an 11-12% speedup on AArch64 macOS compared with the tail-calling interpreter. Those are averages across a benchmark set, so run your own workload before you count on a gain.

Two release-packaging changes sit alongside it: the official Windows 64-bit binaries now use the tail-calling interpreter, and the official macOS installers include free-threading support by default.

What should you check before upgrading?

  • Text I/O without an explicit encoding. This is the change most likely to alter existing behavior.
  • Import-time side effects and import errors, if you plan to adopt lazy imports. Both move to first use.
  • Packages that run code from import lines in .pth files. PEP 829 adds .start files with pkg.mod:callable entry points, import lines in .pth files are silently deprecated, and they're ignored when a matching .start file exists.
  • Memory behavior. The incremental garbage collector that shipped in Python 3.14.0 through 3.14.4 was reverted to the generational GC from 3.13 after reports of memory pressure in production. 3.14.5 and later, and 3.15, use the generational one.
  • C extensions. PEP 803 adds a stable ABI for free-threaded builds (abi3t). The docs list meson-python, scikit-build-core and Maturin as build tools with support.
  • macOS 27.0 with tkinter or IDLE. Python.org warns that dialogs may hang the app there, an OS behavior change believed to affect current Tk versions, tracked in CPython issue 158053.
  • The Removed and Porting sections of the official What's New page, which list removals this article doesn't cover.

Wrapping Up

Python 3.15 is mostly additive, but the UTF-8 default is the one to test today. Run your test suite on 3.15 with the encoding warning enabled, then try lazy imports on the entry point of your slowest-starting CLI or service.

Sources

Frequently Asked Questions

What's new in Python 3.15?

Python 3.15 adds explicit lazy imports (PEP 810), makes UTF-8 the default encoding (PEP 686), and introduces frozendict and sentinel built-ins plus unpacking in comprehensions. It also adds the Tachyon sampling profiler, enables frame pointers by default, and upgrades the experimental JIT.

When was Python 3.15 released?

Python 3.15.0 was released on October 9, 2026, according to the Python.org release page. It follows Python 3.14 by about a year.

Does Python 3.15 use UTF-8 by default?

Yes. Python 3.15 uses UTF-8 as the default encoding independent of the system environment, so open() without an encoding argument reads and writes UTF-8. You can restore the old behavior with PYTHONUTF8=0 or -X utf8=0, or opt into the locale encoding per call with encoding='locale'.

How do I use lazy imports in Python 3.15?

Add the lazy keyword at module scope, for example lazy import json or lazy from pathlib import Path. Python creates a proxy and loads the module the first time you use the name. To apply it globally, use the -X lazy_imports option or the PYTHON_LAZY_IMPORTS environment variable and set it to all.

What is Tachyon in Python 3.15?

Tachyon is a statistical sampling profiler available as profiling.sampling. It captures stack traces from a running process, so you can attach by PID without modifying code or restarting. It can output pstats data, collapsed stacks, HTML flame graphs, Gecko format and heatmaps.

What is frozendict in Python 3.15?

frozendict is a new immutable built-in mapping type. It doesn't inherit from dict, it's hashable when all its keys and values are hashable, and it preserves insertion order while ignoring order in comparisons. Code that checks isinstance(x, dict) needs updating to accept it.