Python 3.15: Small Features That Pack a Punch

Python 3.15.0b1 is feature-frozen, and while the big headlines go to lazy imports and the tachyon profiler, several smaller changes deserve attention. Here's what's new and why you should care.

Asyncio TaskGroup Cancellation

TaskGroup is Python's structured concurrency primitive. Until now, gracefully cancelling a TaskGroup required a workaround: raise a custom exception inside the group, catch it with contextlib.suppress, and rely on ExceptionGroup handling. That was awkward.

# Old way (still works, but ugly)
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()

Python 3.15 introduces TaskGroup.cancel():

async with asyncio.TaskGroup() as tg:
    tg.create_task(run())
    tg.create_task(run())
    if await wait_for_signal():
        tg.cancel()  # No exceptions raised

cancel() cancels all tasks in the group without raising any exceptions. No more ExceptionGroup gymnastics. This is a direct improvement for any code that needs to tear down concurrent work on demand.

Context Managers as Universal Decorators

Using @contextmanager as a decorator has been possible since Python 3.3, but it broke on async functions, generators, and async generators. The decorator would execute immediately, not wrapping the full lifecycle.

@contextmanager
def duration(message: str) -> Iterator[None]:
    start = time.perf_counter()
    try:
        yield
    finally:
        print(f"{message} elapsed {time.perf_counter() - start:.2f}s")

# Before 3.15: this would NOT work correctly
@duration('async workload')
async def async_workload():
    ...

In Python 3.15, ContextDecorator (the base of @contextmanager) now detects the wrapped function type and ensures the decorator covers the entire lifespan. This makes context managers the cleanest way to write decorators—no more footguns with async or generators.

Thread-Safe Iterators

Iterators are not thread-safe by default. Sharing an iterator across threads can cause skipped values or corrupted state. Python 3.15 adds threading.serialize_iterator to wrap any iterator with a lock:

import threading

def stream_events() -> Iterator[str]:
    while True:
        yield blocking_get_event(...)

events = threading.serialize_iterator(stream_events()) with ThreadPoolExecutor() as executor: fut1 = executor.submit(consume, events) fut2 = executor.submit(consume, events)


There's also `threading.synchronized_iterator` (decorator form) and `threading.concurrent_tee` which duplicates values across multiple iterators—similar to `itertools.tee` but thread-safe.

```python
source1, source2 = threading.concurrent_tee(squares(10), n=2)

Previously, you'd need queue.Queue to synchronize consumption. These new utilities let you keep your iterator abstractions intact in multi-threaded code.

Counter xor Operation

collections.Counter already supports +, -, &, and |. Python 3.15 adds ^ (xor):

c = Counter(a=3, b=1)
d = Counter(a=1, b=2)
print(c ^ d)  # Counter(a=2, b=1)

Xor is equivalent to (c | d) - (c & d). It's a niche operation—think symmetric difference of multisets. Handy for completeness, but you'll likely use it rarely.

Immutable JSON Objects

With frozendict finally landing in Python 3.15, you can now parse JSON directly into immutable structures:

import json
from types import MappingProxyType as frozendict  # or from frozendict import frozendict

result = json.loads('{"a": [1, 2, 3, 4]}', array_hook=tuple, object_hook=frozendict)
# result == frozendict({'a': (1, 2, 3, 4)})

The new array_hook parameter complements the existing object_hook. This is useful when you need hashable JSON representations for caching or as dictionary keys.

Why These Matter

Each of these features solves a real pain point: graceful cancellation in asyncio, decorators that work everywhere, thread-safe iteration without queues, and immutable JSON. They're not flashy, but they'll make your code simpler and more correct.

What You Should Do Now

  • Update your Python 3.15 dev environment and try TaskGroup.cancel() in your async code.
  • Replace any @contextmanager decorator workarounds with the new universal support.
  • Audit multi-threaded iterator sharing and switch to threading.serialize_iterator where applicable.
  • Experiment with frozendict and array_hook for caching JSON payloads.