Caching an async function is trickier than expected, this article walks through why that is and how to use Asyncio primitives to solve the problem.
If you've used asyncio for some time you've probably noticed a few things that work differently to the synchronous counter parts.
I recently had to add some caching to an asyncio function. Let's say something like:
async def get_user(user_id: UUID) -> User:
...
Of course naturally we want to use functools.cache, but past experience have taught me that it doesn't work out of the box.
Generally the decorators won't work in both sync and async contexts. This is due to the fact that an async function returns immediately when invoked, and is only actually executed when we await whatever was returned.
But what actually happens here? What's being cached? How does it work?
Invoking function() returns a coroutine object which was cached. The object track the state of execution of the async code, await will advance the state, it can therefore not be awaited more than once.
There are other objects that are awaitable, unlike coroutines, asyncio.Task tracks the result of the coroutine irrespective of the coroutine's state.
As shown, this can be awaited as many times as you want, which is exactly what we need here. We can create a simple wrapper around a coroutine function:
def convert_to_task[**P, R](fn: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, asyncio.Task[R]]:
"""Wrap a coroutine function to make it a task which is cacheable."""
@wraps(fn)
def _fn(*args: P.args, **kwargs: P.kwargs) -> asyncio.Task[R]:
return asyncio.create_task(fn(*args, **kwargs))
return _fn
We can now compose convert_to_task with functools.cache to make an async function cacheable:
Whilst there are off-the-shelf solutions (e.g. aiocache) for this problem, I find that they are missing the simplicity I crave from the functools version. This is probably a signal that we're missing this utility in the standard library.
My solution composes asyncio primitives in a simple way, it's easy enough that you can plausibly reimplement this each time you need it. In lieu of an official version, I believe this is currently the most 'standard' way to achieve caching.
Finally, though you don't need to understand the difference between a coroutine object and a Task or even a Future object, it helps when you're looking to implement advanced asyncio functionality. I was able to drastically simplify the problem by understanding how a Task works.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Asyncio Is Neither Fast Nor Slow | 0 | 7.6 | 30-01-2026 |
| 2 | rsloop: An Event Loop for Asyncio Written in Rust | 0 | 10 | 20-04-2026 |
| 3 | Как отменять задачи в asyncio без зависаний и незавершённой очистки | 0 | 6.4 | 29-07-2026 |
| 4 | CancelledError — не просто очередная ошибка. Разбираемся, как устроена отмена задач в asyncio | -1 | 6.26 | 21-06-2026 |
| 5 | Semantic Caching for LLMs: FastAPI, Redis, and Embeddings | 0 | 10 | 29-04-2026 |
| 6 | Как устроены задачи (Task) в asyncio | 0 | 9.46 | 24-02-2026 |
| 7 | Разбор threading vs multiprocessing vs asyncio в Python | 0 | 6.08 | 03-02-2026 |
| 8 | Событийный цикл в asyncio: как Python-код работает поверх механизмов Linux | 0 | 7.96 | 14-02-2026 |
| 9 | django-modern-rest: REST With Types and Async Support | 0 | 24.29 | 23-04-2026 |
| 10 | 5 слоев кэширования в веб-приложениях: Полное руководство для Python-разработчиков | 0 | 15.81 | 24-05-2026 |