A quick peek into Python 3.15
Python 3.15 introduces many changes and new features and is a big release in many years.
A quick peek into Python 3.15
Python 3.15 introduces many changes and new features and is a big release in many years.

Python 3.15
Python 3.15b1 was released in May 7 and it seems like a harbinger of a number of interesting new features and changes that are coming in Python.
The official what-is-new is already published and you can read it for a quick summary. We deep dive a bit in this article on some of these changes.
Lazy Imports
When we import a module in Python — it does a lot behind the scenes. It has to locate the file, load and read it from the disk, compile to byte-code and load all top-level code. This is a lot of work for the interpreter.
In many application code, developers import modules without actually using them. This causes a number of unused imports which just add to the script startup time — and slows the code down every time it runs.
For example, take a look at the initial imports of a typical script that uses some well known Python libraries for a sentiment analysis (NLP) pipeline.
import time
# To measure imports
start_imports = time.time()
# loads native backend (CPU/GPU detection, kernels)
import torch
# loads model registry, tokenizers
from transformers import pipeline
# pulls numpy, dateutil, etc.
import pandas as pd
# loads plotting backend, fonts
import matplotlib.pyplot as plt
# styling + matplotlib extensions
import seaborn as sns
end_imports = time.time()
print(f"[DEBUG] Import time: {end_imports - start_imports:.2f} sec")
In my laptop — which is a modern modern Lenovo gaming laptop with 24GB RAM, 16 core CPU running Python 3.11 — this took around 3 seconds. That is actually a lot of time spent in just code import.
Imagine how all this time adds up in maching learning pipelines, github actions and other frequently and long running code invocations. It is a classic example of code overhead and is a good place to optimize to reduce code startup latency.
The idea of a lazy import is to import a module — but don’t load its attributes yet, and postpone full loading till the time of its first attribute access. This speeds up imports and script startup times.
Now, lazy imports have been available in Python since Python 3.5 but it is kind of “hidden” in the importlib module in its *LazyLoader c*lass and the feature was never really advertised much. (Click on the link to see an example).
Python 3.15 adds a keyword lazy which can be used before importing a module. The following are all valid lazy imports:
$ python
Python 3.15.0b1+ (heads/3.15:413663b26a4, May 26 2026, 17:36:10) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> lazy import os
>>> lazy import json as lazy_json
>>> lazy from json import dumps
One of the main caveats is you cannot do a lazy *from module import **
>>> lazy from json import *
File "<python-input-1>", line 1
lazy from json import *
^^^^^^^^^^^^^^^^^^^^^^^
SyntaxError: lazy from ... import * is not allowed
Also lazy imports have to be global, local imports are not allowed.
>>> def f():
... lazy import bigmodule
... bigmodule.bigfunc()
...
File "<python-input-0>", line 2
lazy import bigmodule
^^^^^^^^^^^^^^^^^^^^^
SyntaxError: lazy import not allowed inside functions
A lazy module is a proxy for the actual module, which delays the loading of the actual module till the first attribute access or usage.
Due to this, a significant caveat is that if the module doesn’t actually exist — the exception is delayed and can occur at the time of accessing it first — and crash your code.
For example, this would have caused an “ImportError” in a regular import upon module load — but in this case is deferred till the method mymodule.func executes.
lazy import os
# mymodule doesn't exist
lazy import mymodule
def f():
print(os.name)
# Exception occurs here
res = mymodule.func(os.name)
print(res)
if __name__ == "__main__":
f()
A lazily imported module behaves just like the regular module when one inspects or introspects it.
For example, here is introspecting the module copy after lazily importing it. It just looks as if a regular import is done.
>>> lazy import copy
>> type(copy)
<class 'module'>
>>> dir(copy)
['Error', '__all__', '__builtins__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__s
pec__', '_atomic_types', '_copy_atomic_types', '_copy_builtin_containers', '_deepcopy_dict', '_deepcopy_d
ispatch', '_deepcopy_frozendict', '_deepcopy_list', '_deepcopy_method', '_deepcopy_tuple', '_keep_alive',
'_reconstruct', 'copy', 'deepcopy', 'dispatch_table', 'error', 'replace']
However if one inspects the code disassembly, one can see the difference.
>>> import dis
>>> dis.dis("import copy")
0 RESUME 0
1 LOAD_SMALL_INT 0
LOAD_COMMON_CONSTANT 7 (None)
IMPORT_NAME 0 (copy)
STORE_NAME 0 (copy)
LOAD_COMMON_CONSTANT 7 (None)
RETURN_VALUE
>>> dis.dis("lazy import copy")
0 RESUME 0
1 LOAD_SMALL_INT 0
LOAD_COMMON_CONSTANT 7 (None)
IMPORT_NAME 1 (copy + lazy)
STORE_NAME 0 (copy)
LOAD_COMMON_CONSTANT 7 (None)
RETURN_VALUE
The modified byte code for IMPORT_NAME (copy + lazy) creates a new lazy import object than executing the import immediately.
For more information and detailed technical specs, check out the PEP 810 which introduced the change.
The “frozendict” type — When dict freezes over
Python have had sets and frozensets for a while — in fact frozenset was introduced along with sets in Python 2.4 a long time back. However frozendict which is the frozen — or immutable version of a dict is making its appearance in Python 3.15 — thanks to PEP 814.
The frozendict is very much like the dict except that it is missing all of its mutable methods and operations.
>>> d = dict.fromkeys((1,2,3))
>>> f = frozendict.fromkeys((1,2,3))
>>> d
{1: None, 2: None, 3: None}
>>> f
frozendict({1: None, 2: None, 3: None})
>>> d[1] = 1
>>> # frozendict can't be modified
>>> f[1] = 1
Traceback (most recent call last):
File "<python-input-23>", line 1, in <module>
f[1] = 1
~^^^
TypeError: 'frozendict' object does not support item assignment
>>> d.update(dict.fromkeys((4,5,6)))
>>> d
{1: 1, 2: None, 3: None, 4: None, 5: None, 6: None}
>>> f.update(dict.fromkeys((4,5,6)))
Traceback (most recent call last):
File "<python-input-26>", line 1, in <module>
f.update(dict.fromkeys((4,5,6)))
^^^^^^^^
AttributeError: 'frozendict' object has no attribute 'update'. Did you mean to use a 'dict' object?
>>> del d[1]
>>> del f[1]
Traceback (most recent call last):
File "<python-input-28>", line 1, in <module>
del f[1]
~^^^
TypeError: 'frozendict' object doesn't support item deletion
A frozendict being immutable, is hashable — so can be used as a key for other dictionaries. Here is an example of a frozendict instance being used as a key inside another frozendict instance.
>>> f1 = frozendict.fromkeys((1,2,3))
>>> f2 = frozendict({f1: None})
>>> f2
frozendict({frozendict({1: None, 2: None, 3: None}): None})
frozendicts also support merge — with other dicts or frozendicts.
>>> f1 = frozendict.fromkeys((1,2,3))
>>> f2 = frozendict.fromkeys((4,5,6))
>>> d2 = dict.fromkeys((4,5,6))
>>> f1 | f2
frozendict({1: None, 2: None, 3: None, 4: None, 5: None, 6: None})
>>> f1 | d2
frozendict({1: None, 2: None, 3: None, 4: None, 5: None, 6: None})
So, what is the usage of a frozendict ?
There are use cases where an immutable mapping is desired as opposed to a mutable one — where that mapping itself is usable as a dictionary key.
A well known example is the functools.lru_cache in the standard library.
Here is a toy example of trying to use functools.lru_cache with a function accepting a dictionary.
# lrucache_example.py
from functools import lru_cache
@lru_cache(maxsize=128)
def compute(config: dict):
print("Computing...")
return sum(config.values())
d = ({i:i+1 for i in range(10)})
compute(d)
Running this immediately fails:
$ python lrucache_example.py
Traceback (most recent call last):
File "frozendict_usage.py", line 28, in <module
>
compute(d)
~~~~~~~^^^
TypeError: unhashable type: 'dict'
But with frozendict — all it takes is to convert the dictionary into a frozendict and the code works.
Just replace the line,
from functools import lru_cache
@lru_cache(maxsize=128)
def compute(config: dict):
print("Computing...")
return sum(config.values())
d = frozendict({i:i+1 for i in range(10)})
compute(d)
# Cached 2nd time, so doesn't print "Computing..."
compute(d)
$ python lrucache_example.py
Computing...
The new built-in sentinel type
Sentinel values are placeholders or special values — which are commonly useful in programming. The most common use case of an explicit sentinel is to distinguish between a specific guard value and the None built-in type.
MISSING = object()
def get_value(d, key, default=MISSING):
if key in d:
return d[key]
if default is not MISSING:
return default
raise KeyError(key)
For example, the above pattern using sentinel allows to distinguish between:
- User passed “None”
- User didn’t pass anything
Another common example is specific stop values — such as those used as poison pills inside Queues.
from queue import Queue
import threading
STOP = object()
def worker(q):
while True:
item = q.get()
if item is STOP:
break
print(f"Processing {item}")
q.task_done()
q = Queue()
threading.Thread(target=worker, args=(q,), daemon=True).start()
for i in range(5):
q.put(i)
q.put(STOP) # poison pill
There are many other use cases for sentinels — for example default keyword arguments for functions — with most use cases being to distinguish between a specific valid value and “None”.
Apparently there exists a multitude of sentinels in the Python stdlib itself!
This PEP was created in 2021 and has undergone multiple discussions and revisions, since the idea of a single sentinel type — was something a bit controversial and not universally accepted initially by the community. However it seems to have finally made it in Python 3.15.
The new sentinel type puts an end to the chaos and confusion by introducing a very specific sentinel type into the Python language.
The new sentinel accepts a string (str) and generates a unique sentinel object. If you generate another even using the same name, it is a different object altogether!
>>> MISSING = sentinel("MISSING")
>>> MISSING
MISSING
>>> MISSING2 = sentinel("MISSING")
# As expected
>>> MISSING is MISSING
True
# Not same
>>> MISSING is MISSING2
False
You can use the optional repr argument to change the representation of the sentinel.
>>> MISSING = sentinel("MISSING", repr="MISSING PLACEHOLDER")
>>> MISSING
MISSING PLACEHOLDER
Sentinels are truthy objects — it means their boolean evaluation is always True.
>>> bool(MISSING)
True
NOTE: This can cause semantic confusions, if one doesn’t understand the object is sentinel and instead tries to figure its connotation from its name. For example in this case MISSING does have the semantic meaning of False rather than True.
Sentinels with the same name being different objects, the correct way to use them is to declare a single sentinel in one well known module globally and reuse it in other modules.
**Comprehension Unpacking
For nested containers — lists, sets or dictionaries, writing inline comprehensions to iterate through them was always a bit tricky in Python.
For example in Python 3.14 and earlier:
>>> lists=[list(range(i, i+3)) for i in range(5)]
>>> lists
[[0, 1, 2], [1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5, 6]]
Writing a single comprehension that converts this to a flat list:
>>> [item for item in l for l in lists]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'l' is not defined
The mental model here was →
- Inner iterator:
l in lists - Outer iterator:
item in l
But this breaks down in Python as Python evaluates it as,
for item in l: # <-- outer
for l in lists: # <-- inner
...
and fails — as clearly lists is undefined in the inner loop.
The correct list comprehension is a slightly non-intuitive,
>>> [item for l in lists for item in l]
[0, 1, 2, 1, 2, 3, 2, 3, 4, 3, 4, 5, 4, 5, 6]
As this translates (correctly) to,
for l in lists: # <--- outer
for item in l: # <--- inner
Anyway, with Python 3.15, one doesn’t need to worry about this mental model as comprehensions allow unpacking inline without worrying about an inner iterator. This is the equivalent Python 3.15 code.
>>> lists=[list(range(i, i+3)) for i in range(5)]
>>> [*item for item in lists]
[0, 1, 2, 1, 2, 3, 2, 3, 4, 3, 4, 5, 4, 5, 6]
Similar expansion works for set and dict comprehensions as well.
>>> sets=[tuple(range(i, i+3)) for i in range(5)]
>>> sets
[(0, 1, 2), (1, 2, 3), (2, 3, 4), (3, 4, 5), (4, 5, 6)]
>>> {*s for s in sets}
{0, 1, 2, 3, 4, 5, 6}
With dictionaries, it is a bit more interesting since two level unpacking applies — one for the keys and one for (key, value) pairs as a dictionary itself.
>>> dicts=[dict.fromkeys(range(i, i+3)) for i in range(5)]
>>> dicts
[{0: None, 1: None, 2: None}, {1: None, 2: None, 3: None}, {2: None, 3: None, 4: None}, {3: None, 4: None
, 5: None}, {4: None, 5: None, 6: None}]
# Fetch unique keys as a set
>>> {*d for d in dicts}
{0, 1, 2, 3, 4, 5, 6}
>>> Fetch all keys as a list with dups
>>> [*d for d in dicts]
[0, 1, 2, 1, 2, 3, 2, 3, 4, 3, 4, 5, 4, 5, 6]
# Fetch unique (key,val) pair as a dictionary
>>> {**d for d in dicts}
{0: None, 1: None, 2: None, 3: None, 4: None, 5: None, 6: None}
For unpacking more information about this feature, check out PEP 448 .
A dedicated profiling package
This release unifies Python’s profiling tools — which are split across the profile and cProfile modules into a single new profiling package.
Python 3.15 also introduces a new statistical sampling profiler named tachyon. This profiler enables low-overhead performance analysis of running Python processes — as opposed to the current profilers which traces and instruments every function call.
Tachyon will support multiple threads, async functions, attaching to running processes and even free threading builds. This is an improvement from cProfile which observes only the main thread.
The new profiling module combines tachyon and the existing cProfile modules into one namespace namely profiling.
- profling.tracing — The old cProfile module is moved here
- profiling.sampling — The new tachyon profiler
The existing pure Python profile module gets deprecated in Python 3.15 and is planned to be removed in Python 3.17.
This is a very good feature — as the current cProfile module was not very useful and developers were not using classical profiling as much as “unofficial” time sampling and ad-hoc measures a tool to measure performance of their Python code.
Hopefully tachyon will change that.
Read more about this in PEP 799.
Better error messages
With Python 3.15, whenever an AttributeError is raised, the interpreter tries to be more helpful now.
For example, if you thought you were using a set and not a list in earlier Python versions:
>>> l=[1,2]
>>> l.add(3)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'list' object has no attribute 'add'
In Python 3.15, the interpreter guesses you may have been expecting a set object instead:
>>> l.add(3)
Traceback (most recent call last):
File "<python-input-1>", line 1, in <module>
l.add(3)
^^^^^
AttributeError: 'list' object has no attribute 'add'. Did you mean to use a 'set' object?
Similarly, when a mutable method is called on an immutable type, the interpreter gets smart again:
>>> t = (1,2,3)
>>> t.append(4)
Traceback (most recent call last):
File "<python-input-11>", line 1, in <module>
t.append(4)
^^^^^^^^
AttributeError: 'tuple' object has no attribute 'append'. Did you mean to use a 'list' object?
When an attribute doesn’t match any known ones (via Levenshtein distance) — the interpreter checks a common table of well known method names for suggestions now — across languages!
>>> s="Python"
>>> s.toUpper()
Traceback (most recent call last):
File "<python-input-16>", line 1, in <module>
s.toUpper()
^^^^^^^^^
AttributeError: 'str' object has no attribute 'toUpper'. Did you mean '.isupper' instead of '.toUpper'?
Note that the implementation can be better here since it should have ideally suggested “upper” instead of “isupper”.
However the changes are not very consistent across types. For example I found dictionaries are still not very helpful.
>>> d1={1:2}
>>> d2={2:3}
>>> d1.union(d2)
Traceback (most recent call last):
File "<python-input-24>", line 1, in <module>
d1.union(d2)
^^^^^^^^
AttributeError: 'dict' object has no attribute 'union'
The name “union” is not very far from “update” — which was the right method here, so I am not sure why it didn’t suggest it. But this works.
>>> d1.udpate(d2)
Traceback (most recent call last):
File "<python-input-25>", line 1, in <module>
d1.udpate(d2)
^^^^^^^^^
AttributeError: 'dict' object has no attribute 'udpate'. Did you mean '.update' instead of '.udpate'?
However this works in previous Python versions also — so it is nothing new (meh).
Altogether this specific improvement seems to be put together speedily — perhaps more work will be done on this in the future across other classes of error messages as well.
Here is looking forward to continuing improvements in Python. I will continue discussing these as they appear in the wild in future releases.
메타데이터
- post_id
- 204b1382a74a
- slug
- a-quick-peek-into-python-3-15-204b1382a74a
- url
- https://medium.com/@anandpillai/a-quick-peek-into-python-3-15-204b1382a74a
- canonical_url
- https://medium.com/@anandpillai/a-quick-peek-into-python-3-15-204b1382a74a
- author_url
- https://medium.com/@anandpillai
- status
- ok
- fetched_at
- 2026-09-17 11:19:09