Python 3.15's Most Useful Changes Flew Under the Radar
Every Python version has two announcements. The first is the one that trends on HN - the PEPs with flashy names, the ones that land in the release notes headline. The second happens a few weeks later in a quiet blog post that says: “I actually read the whole changelog.”
Jamie Chang did the second thing for Python 3.15. The big names this year are lazy imports and the Tachyon profiler. But under those, there are four changes that solve problems every Python developer has hit at least once.
⚡ TL;DR
- What it fixes: Years-old asyncio and threading awkwardness, plus a context manager edge case you’ve probably debugged at midnight
- Why it matters: The workarounds for these were ugly enough that people avoided the patterns entirely
- Best for: Anyone writing asyncio workers, multi-threaded data pipelines, or utility decorators
- Main differentiator: These aren’t new capabilities - they’re existing capabilities made to actually work as documented
- Caveat: Python 3.15 is in beta; feature freeze is set, ship date is later this year
The TaskGroup.cancel() you always wanted
TaskGroup was the best structured concurrency addition in recent Python. The missing piece was: how do you stop everything gracefully from the outside?
Before 3.15, the pattern was to invent an exception:
class Interrupt(Exception):
...
with suppress(Interrupt):
async with asyncio.TaskGroup() as tg:
tg.create_task(run())
tg.create_task(run())
if await wait_for_signal():
raise Interrupt()
This works. The custom exception gets swallowed by contextlib.suppress and the tasks cancel cleanly. But it requires knowing that suppress works with ExceptionGroup (itself a 3.12 addition that most people haven’t fully absorbed yet), writing a throwaway exception class, and remembering the pattern each time.
In 3.15:
async with asyncio.TaskGroup() as tg:
tg.create_task(run())
tg.create_task(run())
if await wait_for_signal():
tg.cancel()
No exception. No suppress. No custom class. Just tg.cancel().
Context manager decorators that don’t lie
You probably know that a @contextmanager function can double as a decorator since Python 3.3:
@contextmanager
def duration(message: str) -> Iterator[None]:
start = time.perf_counter()
try:
yield
finally:
print(f"{message} elapsed {time.perf_counter() - start:.2f} seconds")
@duration('workload')
def workload():
...
What you may have found the hard way: it breaks silently on anything that isn’t a plain synchronous function.
@duration('async workload')
async def async_workload():
... # decorator runs instantly, measures nothing
@duration('generator workload')
def workload():
while True:
yield ... # same issue
When you call an async def, you get a coroutine object back immediately - no code runs yet. The decorator wraps that object, measures the time from “you called it” to “Python yielded the coroutine back,” and that time is essentially zero. Your context manager fires and exits before the work even starts.
In 3.15, ContextDecorator checks the type of the wrapped function and handles async functions, generators, and async generators correctly - covering the full lifecycle in each case, not just the moment of the call.
This is one of those changes where seeing it in the changelog makes you go back and audit every context manager decorator you’ve written in the past three years.
Thread-safe iterators without Queue gymnastics
Let’s say you have a generator:
def stream_events(...) -> Iterator[str]:
while True:
yield blocking_get_event(...)
And you want to fan it out to a thread pool. Before 3.15, sharing an iterator across threads was undefined behavior - you could get skipped values, doubled values, or corrupted iterator state. The standard workaround was to put everything behind a Queue, which meant rewriting your generator as a producer thread and your consumers to read from the queue rather than iterate. Different abstraction, different code.
In 3.15, three new primitives:
import threading
# Wrap an existing iterator - one value out per call, threadsafe
events = threading.serialize_iterator(stream_events(...))
# Or as a decorator on the generator function itself
@threading.synchronized_iterator
def stream_events(...) -> Iterator[str]:
...
# Or broadcast - each consumer gets every value
source1, source2 = threading.concurrent_tee(squares(10), n=2)
The existing Queue-based pattern still works. But these three let you keep the iterator abstraction intact across threads.
Immutable JSON objects (frozendict)
PEP 814 ships frozendict in 3.15. It’s an immutable, hashable mapping - the tuple to list’s dict. More immediately useful than the type itself is the change to json.loads:
json.loads(
'{"a": [1, 2, 3, 4]}',
array_hook=tuple,
object_hook=frozendict
)
# frozendict({'a': (1, 2, 3, 4)})
Before this, json.load had object_hook but nothing for arrays. To get a fully immutable JSON tree, you had to post-process recursively. Now both hooks exist and you can get a completely deep-frozen Python representation in one pass.
What HN was actually arguing about
353 points, 170 comments. The discussion, as usual, drifted.
One commenter noticed that the article’s code example had lazy from typing import Iterator - which looked like the lazy import feature had snuck into the post and confused people who hadn’t yet encountered it.
The sharpest undercurrent in the thread was about Python’s trajectory:
“I was so into Python for 10 years… But have deleted 100k+ lines this year already moving them to faster languages in a post-AI codebot world. Mostly moving to Go these days.”
One more:
“Funny how we may have to wait even longer for LLMs to pick up this update in their pre-training.”
That second comment is quietly interesting. Python 3.15 is still in beta. The LLMs you’re using right now were trained on data that predates this release. When you ask an AI assistant to write asyncio code that cancels a TaskGroup, it will give you the exception-class pattern - because that’s all that existed when its training data was collected. It’s not wrong. It just doesn’t know about tg.cancel() yet. Keep that gap in mind.
Tradeoffs and timing
These changes are real, but they’re also still betas. Feature freeze means the features are locked; it does not mean all edge cases are resolved. If you’re evaluating production Python today, you’re looking at 3.13 or 3.14.
For these four features specifically:
| Feature | Who benefits most | What to read |
|---|---|---|
TaskGroup.cancel() | asyncio service code | asyncio.TaskGroup docs |
ContextDecorator fix | Utility decorator libraries | contextlib docs |
| Thread-safe iterators | Data pipelines, async streams | threading.serialize_iterator |
frozendict + json.loads | Config parsers, immutable data models | PEP 814 |
Original article: Python 3.15: features that didn’t make the headlines by Jamie Chang. HN thread: news.ycombinator.com/item?id=48220696
Hoang Yell
A software developer and technical storyteller. I read Hacker News every day and retell the best stories here — in English and Vietnamese — for curious people who don't have time to scroll.