{"slug": "python-3-15-features-that-didn-t-make-the-headlines", "title": "Python 3.15: features that didn't make the headlines", "summary": "Several underreported features in Python 3.15, including a new `TaskGroup.cancel()` method that allows for graceful cancellation of asynchronous task groups without raising exceptions. It also details improvements to `ContextDecorator` that now properly support async functions, generators, and async generators, and mentions the introduction of thread-safe iterators.", "body_md": "It's that time of the year again, a new version of Python is just around the corner. With the [Python 3.15.0b1](https://docs.python.org/3.15/whatsnew/3.15.html) feature freeze, we know what's coming to Python later this year. There are so many big features coming including [lazy imports](https://docs.python.org/3.15/whatsnew/3.15.html#whatsnew315-lazy-imports) and the [tachyon profiler](https://docs.python.org/3.15/whatsnew/3.15.html#whatsnew315-sampling-profiler) which I [previously covered](./benchmarking-free-threading-performance-with-tachyon.html).\n\nLast year, I really enjoyed [investigating](./python-314-3-smaller-features.html) the smaller features of Python 3.14. I found that many of those features were just as interesting as the big PEPs and deserve a lot more attention. This year the situation is no different.\n\n## Asyncio Taskgroup Cancellation\n\nThere are not many Asyncio changes in this releases. The main feature to come out here is the ability to cancel a `TaskGroup`\n\ngracefully.\n\n[TaskGroup](https://docs.python.org/3/library/asyncio-task.html#asyncio.TaskGroup) is a form of [structured concurrency](https://en.wikipedia.org/wiki/Structured_concurrency), it enables developers to create multiple concurrent tasks in a clean way.\n\n```\nasync with asyncio.TaskGroup() as tg:\n    tg.create_task(run())\n    tg.create_task(run())\n    # Waits for all the tasks to complete\n```\n\nSuppose we want to wait in the background for a signal of sorts to interrupt the taskgroup's execution, it's seems like something simple to do in asyncio, but in reality it's somewhat awkward to do this.\n\n```\nclass Interrupt(Exception):\n    ...\n\nwith suppress(Interrupt):\n    async with asyncio.TaskGroup() as tg:\n        tg.create_task(run())\n        tg.create_task(run())\n        if await wait_for_signal():\n            raise Interrupt()\n```\n\nThis works because exceptions raised within a task group cause other tasks to cancel. The custom `Interrupt`\n\nexception is raised as part of a `ExceptionGroup`\n\nwhich then gets filtered by [contextlib.suppress](https://docs.python.org/3/library/contextlib.html#contextlib.suppress), resulting in a graceful exit.\n\nThe way suppress works with ExceptionGroup is yet another overlooked feature from 3.12. This is a change I learnt by accident when researching this article.\n\nThe new [TaskGroup.cancel](https://docs.python.org/3.15/library/asyncio-task.html#asyncio.TaskGroup.cancel) makes this process a lot easier:\n\n```\nasync with asyncio.TaskGroup() as tg:\n    tg.create_task(run())\n    tg.create_task(run())\n    if await wait_for_signal():\n        tg.cancel()\n```\n\nUnlike before it's so simple there's hardly any point in explaining. It simply cancels the group without raising any exceptions.\n\n## Context Manager Improvements\n\nDecorators are surprisingly hard to write, so much so that it's become a go-to interview question. But did you know that context managers can also double up as a decorator?\n\n``` php\n@contextmanager\ndef duration(message: str) -> Iterator[None]:\n    start = time.perf_counter()\n    try:\n        yield\n    finally:\n        print(f\"{message} elapsed {time.perf_counter() - start:.2f} seconds\")\n```\n\nHere I have a very commonly used context manager to print out the duration spent in the block. Ever since Python 3.3 we could directly use it as a decorator too:\n\n``` python\n@duration('workload')\ndef workload():\n    ...\n\n# Or simple as a wrapper\n\nduration('stuff')(other_workload)(...)\n```\n\nBut whilst it's convenient, there are cases where it doesn't work at all:\n\n``` python\n@duration('async workload')\nasync def async_workload():\n    ...\n\n@duration('generator workload')\ndef workload():\n    while True:\n        yield ...\n```\n\nIterators, async functions and async iterators don't work well here because they have different semantics to standard functions. When you call them they return immediately with a generator object, coroutine function and async generator object respectively. So the decorator completes immediately as opposed to the entire lifecycle what it's wrapping.\n\nThis is an unfortunate problem I've encountered many times, and it's often a problem for normal decorators too. But this has changed in 3.15, now the `ContextDecorator`\n\nwill check the type of the function it's wrapping and ensure that the decorator covers the entire lifespan.\n\nIn my opinion, this now makes context managers the best way to create decorators! It avoids some of the common footguns and provides cleaner syntax. I recommend more people start using it this way.\n\n## Thread Safe Iterators\n\nIterators are one of the foundations of modern Python. The iterator type allows us to separate data sources from data consumers as below, resulting in cleaner abstractions:\n\n``` php\nlazy from typing import Iterator\n\ndef stream_events(...) -> Iterator[str]:\n    while True:\n        yield blocking_get_event(...)\n\nevents = stream_events(...)\n\nfor event in events:\n    consume(event)\n```\n\nBut this abstraction breaks when using threading or free-threading. An iterator by default is not threadsafe, therefore we may see skipped values or just broken internal iterator state.\n\nThis is solved in 3.15 with [threading.serialize_iterator](https://docs.python.org/3.15/library/threading.html#threading.serialize_iterator), we simply wrap our original iterator with this and voila:\n\n``` python\nimport threading\n\nevents = threading.serialize_iterator(stream_events(...))\n\nwith ThreadPoolExecutor() as executor:\n    fut1 = executor.submit(consume, events)\n    fut2 = executor.submit(consume, events)\n```\n\nThere is also the [threading.synchronized_iterator](https://docs.python.org/3.15/library/threading.html#threading.synchronized_iterator) decorator which just applies `threading.serialize_iterator`\n\nto the result of an generator function.\n\nFinally we also have [threading.concurrent_tee](https://docs.python.org/3.15/library/threading.html#threading.concurrent_tee) that instead of splitting the values will duplicate the values across multiple iterators:\n\n```\nsource1, source2 = threading.concurrent_tee(squares(10), n=2)\n\nwith ThreadPoolExecutor() as executor:\n    fut1 = executor.submit(consume, source1)\n    fut2 = executor.submit(consume, source2)\n```\n\nBefore these utilities existed we primarily relied on [Queue](https://docs.python.org/3/library/queue.html)s to synchronise consumption between threads, with these added in we can avoid changing our abstractions for multi-threaded code.\n\n## Bonus Features\n\nLast year I only highlighted 3 features, but this year there are a lot more updates that intrigue me. Here are 2 more changes that are perhaps less impactful but still very interesting nonetheless.\n\n### Counter xor Operation\n\n[collections.Counter](https://docs.python.org/3/library/collections.html#collections.Counter) is a very useful class. It let's us easily count up the frequency of discrete occurrences. It behaves very similar to a `dict[KeyType, int]`\n\nbut with a ton of useful operations\n\n```\nc = Counter(a=3, b=1)\nd = Counter(a=1, b=2)\nprint(f\"{c + d = }\")  # add two counters together:  c[x] + d[x]\nprint(f\"{c - d = }\")  # subtract (keeping only positive counts)\n```\n\nprints:\n\n```\nCounter(a=4, b=3)\nCounter(a=1, b=0)\n```\n\nBut it has some weirder operations too:\n\n```\nprint(f\"{c & d = }\")  # intersection:  min(c[x], d[x])\nprint(f\"{c | d = }\")  # union:  max(c[x], d[x])\n```\n\nprints:\n\n```\nCounter(a=1, b=1)\nCounter(a=3, b=2)\n```\n\nThe way to think of it is that a `Counter`\n\ncan also represents a discrete set of objects. so in our example, we're essentially doing:\n\n```\n{a_0, a_1, a_2, b_0} & {a_0, b_0, b_1} == {a_0, b_0}\n{a_0, a_1, a_2, b_0} | {a_0, b_0, b_1} == {a_0, a_1, a_2, b_0, b_1}\n```\n\nIn 3.15 we can also add xor to the list:\n\n```\nc = Counter(a=3, b=1)\nd = Counter(a=1, b=2)\n\nc ^ d == c | d - c & d == Counter(a=3, b=2) - Counter(a=1, b=1) == Counter(a=2, b=1)\n```\n\nOnce again this is best explained by our notation from earlier:\n\n```\n{a_0, a_1, a_2, b_0} ^ {a_0, b_0, b_1} == {a_1, a_2, b_1}\n```\n\nI've left this one to the bonus section because I've never used set operations on `Counter`\n\ns and I'm finding it extremely hard to think of a use case for xor specifically. But I do appreciate the devs adding it for completeness.\n\n### Immutable JSON Objects\n\nWith the addition of [frozendict](https://peps.python.org/pep-0814/) in 3.15, we now have the ability to represent all the json types (array, boolean, float, null, string, object) in immutable (hashable) forms.\n\nA change has been made to [json.load](https://docs.python.org/3.15/library/json.html#json.load) and [json.loads](https://docs.python.org/3.15/library/json.html#json.loads) to add `array_hook`\n\nparameter that compliments the `object_hook`\n\nparameter. This now allows us to parse json objects directly into this form:\n\n```\njson.loads('{\"a\": [1, 2, 3, 4]}', array_hook=tuple, object_hook=frozendict) == frozendict({'a': (1, 2, 3, 4)})\n```\n\n", "url": "https://wpnews.pro/news/python-3-15-features-that-didn-t-make-the-headlines", "canonical_source": "https://blog.changs.co.uk/python-315-features-that-didnt-make-the-headlines.html", "published_at": "2026-05-21 11:10:11+00:00", "updated_at": "2026-05-22 08:32:28.781202+00:00", "lang": "en", "topics": ["developer-tools", "open-source"], "entities": ["Python"], "alternates": {"html": "https://wpnews.pro/news/python-3-15-features-that-didn-t-make-the-headlines", "markdown": "https://wpnews.pro/news/python-3-15-features-that-didn-t-make-the-headlines.md", "text": "https://wpnews.pro/news/python-3-15-features-that-didn-t-make-the-headlines.txt", "jsonld": "https://wpnews.pro/news/python-3-15-features-that-didn-t-make-the-headlines.jsonld"}}