What's New in Python 3.15: Lazy Imports, UTF-8, and What Breaks
Python 3.15 lands October 2026. I installed the beta and benchmarked the headline changes: lazy imports (a ~4x startup win), UTF-8 by default, the new sampling profiler, and the old APIs that stop working when you upgrade.

Makoto Horikawa
Backend Engineer / AWS / Django
Python 3.15 lands October 2026. I installed the beta and benchmarked the headline changes: lazy imports (a ~4x startup win), UTF-8 by default, the new sampling profiler, and the old APIs that stop working when you upgrade.
The short version: lazy imports finally make startup fast
Python 3.15 is scheduled for October 1, 2026. As always there's a long changelog, but for most developers the one to care about is explicit lazy imports. "Python startup is slow" has been a running complaint for a decade, and 3.15 does something about it: modules aren't loaded until you actually use them, which makes CLI tools noticeably snappier.
For this article I installed the still-beta Python 3.15 (3.15.0b2) and ran each major change by hand. The goal isn't just "here's what's new" β it's "here's what used to be annoying, how it gets better, and which old code stops working when you upgrade," with real numbers from a real machine.
Here's the summary up front.
| Change | Why you'd care | Kind |
|---|---|---|
| Lazy imports (PEP 810) | Faster startup (~4x in my test) | New capability |
| UTF-8 by default (PEP 686) | Less mojibake | Better (mind the migration) |
| New profiler (PEP 799) | Profile a live process without restarting it | New capability |
| Unpacking in comprehensions (PEP 798) | Flatten lists without a nested for | Better |
| Removed APIs | (migration gotchas) | Stops working |
All the verification code lives in a public GitHub repo. Run uv python install 3.15 and you can reproduce every number here. The JIT compiler also made a comeback in 3.15, but that's a separate story; this article focuses on everything else.
When does Python 3.15 ship, and what's the status now?
The final release is set for October 1, 2026 (per PEP 790, the release schedule). Python ships a new version every October, and 3.15 follows that cadence.
As of June 2026 it's in beta: the feature window (alpha) is closed and it's now bug-fixing and polish. I ran everything below on beta 2, 3.15.0b2.
# Install the beta with uv $ uv python install 3.15 $ python3.15 -VV Python 3.15.0b2 (main, Jun 11 2026, 04:04:48) [Clang 22.1.3]
Because it's beta, behavior can still shift before the final release β later in this article you'll see one feature that the docs say is removed but is actually still present in the beta. Don't ship it to production yet, but it's perfect for checking whether your own code survives 3.15.
Lazy imports make startup fast (PEP 810)
The headline feature. Python convention is to put every import at the top of the file β which means modules you might never use get loaded at startup anyway. A CLI tool that only wants to print --help still drags in dozens of modules first.
The old workaround was to bury heavy imports inside functions. PEP 810 notes that roughly 17% of stdlib imports are already scattered into functions for exactly this reason. Python 3.15 turns the workaround into a language feature: put lazy in front of the import.
# With `lazy`, json is NOT loaded at this point
lazy import json
print("json in sys.modules before first use:", "json" in sys.modules)
# => False (not loaded yet)
# The first time you actually use the name, the real module loads
json.dumps({"hello": "world"})
print("json in sys.modules after first use: ", "json" in sys.modules)
# => True (loaded here)Running it confirms the module stays unloaded until first access. The lazy import line binds a lightweight proxy; the first attribute access "reifies" it, swapping in the real module.
How much faster, measured
Numbers, not vibes. I wrote a script that imports five heavyweight stdlib modules (json, pathlib, argparse, logging, http.client) and timed start-to-exit 30 times, once with normal imports and once with lazy imports it never uses.
| Style | Startup (median) | Modules loaded |
|---|---|---|
| Normal imports | 21.1 ms | 122 |
| Lazy imports | 5.2 ms | 26 |
Startup dropped from ~21 ms to ~5 ms, roughly 4x, and the module count fell from 122 to 26. This is the best case β I imported things and never used them β so your mileage depends on what you import. But deferring heavy libraries you don't always need is a real win: a tool that used to stall for a beat on every invocation now feels instant.
You can retire those in-function imports
The speed is nice, but the thing I appreciate most is readability. For years the standard trick to keep startup fast was to bury a heavy import inside a function. As mentioned above, even the standard library has roughly 17% of its imports scattered into functions for exactly this reason β often not by choice, but to work around the cost of top-level imports.
In 3.15 you can move those back to a lazy import at the top of the file. Your dependencies line up at the top again, so "what does this file depend on?" is answerable at a glance, and you avoid the per-call name resolution that in-function imports pay every time they run. You get the fast startup and the readable dependency list, without choosing between them.
That said, not every in-function import becomes unnecessary. When there's an actual logic reason β pulling in a heavy library only on a specific OS or feature path, or deliberately deferring an import to break a circular dependency β keeping it inside the function still expresses the intent better (lazy import only works at the top level, and it doesn't magically resolve circular imports). Move the imports you scattered purely for startup speed back to the top, and leave the ones that are there for a reason. Hold that line and your readability genuinely improves.
The gotchas
It's not free. Things I hit while testing:
- β’ Module level only.
lazy importworks only at the top level of a file. Not inside functions, not insidetry, and not withfrom x import *. - β’ Errors move later. A lazy import of a missing module doesn't fail on the import line β it fails the first time you use the name.
- β’ Import-time side effects don't fire. Libraries that register things on import won't run that code until first use.
An explicit lazy import is honored by default, no flag needed. To flip the whole program's behavior, use the startup option -X lazy_imports=all. One thing that tripped me up in testing: this option is not a bare flag β it requires a value of all, none, or normal. Writing just -X lazy_imports errors out at startup.
UTF-8 is finally the default (PEP 686)
In 3.15, the default text encoding becomes UTF-8. If you've ever fought Windows over cp1252 or Japanese cp932, this is the change you've been waiting for.
Until now, open() without an explicit encoding followed the OS locale. On Windows that often meant a legacy code page rather than UTF-8, so reading a UTF-8 file produced mojibake. From 3.15, the default is UTF-8 regardless of locale.
# Run on 3.15 (no encoding specified)
import sys
print("sys.flags.utf8_mode:", sys.flags.utf8_mode) # => 1
with open("sample.txt", "w") as f: # no encoding= argument
print("default open() encoding:", f.encoding) # => utf-8
f.write("ζ₯ζ¬θͺγγγΉγ")On 3.15, sys.flags.utf8_mode is 1 (on) with no configuration. On 3.12 the same check returns 0 (off).
To be honest: on Linux and macOS, where the locale is already UTF-8, you'll barely notice β open() was opening UTF-8 anyway. The real beneficiary is Windows. The classic "forgot the encoding, got mojibake" bug is much less likely by default.
The flip side: code that relied on a legacy code page may behave differently in 3.15. To restore the old locale-based behavior, set PYTHONUTF8=0 or pass -X utf8=0 at startup, or opt out per file with open(..., encoding="locale").
A built-in profiler you can attach to a live process (PEP 799)
A profiler tells you where your program spends its time. Python 3.15 ships a new one in the standard library, profiling.sampling. It samples "what's running right now" at a fixed rate, so it measures with very little overhead.
The big difference from the old cProfile is that you can attach to an already-running process β no code changes, no restart. That "why is this production process pegged?" moment now has a first-party answer.
# Run a script and emit an interactive flamegraph
$ python3.15 -m profiling.sampling run -r 10khz \
--flamegraph -o flamegraph.html workload.py
# Attach to a running process (PID 12345), no restart needed
$ python3.15 -m profiling.sampling attach 12345 --mode cpuI profiled a recursive-Fibonacci workload at 10,000 samples/sec. The result made it obvious at a glance that 83.7% of the time went into the recursive Fibonacci call. The flamegraph below (width = share of time spent) is the actual output, embedded as-is. Click a bar to zoom in.
β Flamegraph generated by Python 3.15's profiling.sampling (open in a new tab)
Other output formats include a classic cProfile-style table (--pstats), a per-line heatmap (--heatmap), and a real-time terminal view (--live). You can also restrict what you measure to CPU time only or GIL-holding time only. The old cProfile family has been reorganized under the name profiling.tracing.
Small wins: comprehension unpacking, new built-ins, friendlier errors
Unpacking inside comprehensions (PEP 798)
Flattening a list of lists used to need two for clauses. In 3.15 you can unpack with * directly.
lists = [[1, 2], [3, 4], [5]] # Before (two fors) old = [x for sub in lists for x in sub] # 3.15 (unpack with *) new = [*sub for sub in lists] print(new) # => [1, 2, 3, 4, 5]
Dict comprehensions get {**d for d in dicts} too. Not life-changing, but that unreadable nested-for finally has a cleaner form.
An immutable dict and a real sentinel, built in
An immutable mapping, frozendict (PEP 814), and a unique marker value, sentinel (PEP 661), are now built in β no third-party package required.
# Immutable dict
config = frozendict(host="localhost", port=8080)
config["port"] = 9090
# => TypeError: 'frozendict' object does not support item assignment
# A marker that's distinct from None
MISSING = sentinel("MISSING")
print(MISSING) # => MISSINGfrozendict is hashable and unchangeable, so you can use it as a dict key or pass it as a config that nobody can mutate. sentinel is the clean way to tell "argument omitted" apart from "None was passed explicitly" β the pattern everyone used to hand-roll is now standard.
Error messages that help refugees from other languages
If you reach for a method by its name in another language, Python now suggests the right one. Actual 3.15 output:
>>> [1, 2, 3].push(4)
AttributeError: 'list' object has no attribute 'push'. Did you mean '.append'?
>>> "hello".toUpperCase()
AttributeError: 'str' object has no attribute 'toUpperCase'. Did you mean '.upper'?
>>> {}.put("a", 1)
AttributeError: 'dict' object has no attribute 'put'. Use d[k] = v.JavaScript's .push() and Java's .put() get gently redirected to the Pythonic spelling. One note from testing: that suggestion is added when the exception is rendered, so catching it and printing str(exc) won't show it β format it via the traceback module and it appears.
Code that stops working in 3.15 (migration gotchas)
Jump straight to 3.15 from an old version and some code that used to work will break. A few long-deprecated features are finally gone. I ran the same script on 3.12 and 3.15 to see exactly what disappears.
| Feature | 3.12 | 3.15 | Use instead |
|---|---|---|---|
| sre_compile / sre_parse / sre_constants | present | removed | the re module |
| pathlib.PurePath.is_reserved() | present | removed | os.path.isreserved() |
| http.server.CGIHTTPRequestHandler | present | removed | a real WSGI/ASGI server |
| types.CodeType.co_lnotab | present | removed | co_lines() |
| locale.getdefaultlocale() | present (warns) | still here | getencoding() etc. |
Here's the actual run. [GONE] means removed; [OK] means still usable.
# Python 3.15.0b2 [GONE] sre_compile module: ModuleNotFoundError [GONE] sre_parse module: ModuleNotFoundError [GONE] sre_constants module: ModuleNotFoundError [GONE] pathlib.PurePath.is_reserved(): AttributeError [GONE] http.server.CGIHTTPRequestHandler: AttributeError [GONE] types.CodeType.co_lnotab: AttributeError [OK] locale.getdefaultlocale()
sre_compile and friends are internal regex-engine modules you'd rarely import directly. But some libraries poke at them internally, so if a dependency suddenly breaks on 3.15, this is a likely culprit.
The fun one is the last row. On 3.12, locale.getdefaultlocale() explicitly warns that it's "slated for removal in Python 3.15" β yet on the 3.15 beta it's still there. The docs treat it as removed, but beta 2 hasn't actually dropped it. It'll likely go in the final release, so migrate to getencoding() while the warning is still nagging you. "The docs and the implementation disagree" is the kind of thing you only learn by running the beta.
Separately, re.match() β long confusing because it matches at the start of the string rather than anywhere β gets a clearer alias, re.prefixmatch(), and re.match() is now soft-deprecated (not going away immediately).
Should you upgrade to 3.15?
The final release is October 2026, so this isn't a "bump production today" situation. But 3.15 looks worth the wait. Lazy imports in particular pay off clearly for CLI tools that suffered from slow startup and for apps that pull in a lot of libraries.
The flip side is that UTF-8-by-default and the removed APIs can change behavior during migration. The fix is simple: install the beta and run your code and tests through it once. uv python install 3.15 installs it side by side, so leave your current Python alone and try it in CI. The verification code for this article is in the GitHub repo β a good starting point for your own checks.
When is Python 3.15 released?
uv python install 3.15.What is the headline feature of Python 3.15?
What changes when UTF-8 becomes the default?
Will any code stop working in Python 3.15?
Test environment and sources
Environment: Python 3.15.0b2 (built June 11, 2026) / Linux aarch64, compared against Python 3.12.13. All numbers are measured locally. Because this is a beta, results may change in the final release.
- β’ Verification code (GitHub repo) β every snippet and result from this article
- β’ What's New In Python 3.15 (official docs)
- β’ PEP 790 β Python 3.15 Release Schedule
- β’ PEP 810 β Explicit lazy imports
- β’ PEP 686 β Make UTF-8 mode default / PEP 799 β Sampling profiler / PEP 798 β Unpacking in comprehensions
- β’ Deprecations (the removal list)