Desk Clarity

A field guide to everyday digital problems.

Developer tools / Practical guide

Python lru_cache on async def: Diagnose an Already Awaited Coroutine

Fix coroutine reuse caused by lru_cache on async def; test completed-value caching and its sequential-call limits.

By ON Lab4 min readSources checked November 10, 2026 (KST)

If an async def function wrapped in functools.lru_cache succeeds once and then raises RuntimeError: cannot reuse already awaited coroutine, remove the decorator while diagnosing the failure. The cached object is the coroutine returned by calling the function. It is not the value produced after awaiting that coroutine.

Reproduce the second-await failure

Run this complete example in a script. It makes no network requests and isolates reuse of one cache key.

import asyncio
from functools import lru_cache

@lru_cache(maxsize=8)
async def load(key):
    return key.upper()

async def main():
    first = load("a")
    second = load("a")
    assert first is second
    assert await first == "A"
    try:
        await second
    except RuntimeError as error:
        assert "already awaited coroutine" in str(error)
        print(type(error).__name__, str(error))
    else:
        raise AssertionError("expected coroutine reuse failure")
    assert load.cache_info().hits == 1
    load.cache_clear()

asyncio.run(main())

The cache hit tells you that the wrapper reused an object. It does not establish that awaiting that object is valid. Calling an asynchronous function creates a coroutine; execution happens when that coroutine is awaited or scheduled. A synchronous memoization wrapper stores the immediate return object before any asynchronous result is available.

The official functools documentation cautions against caching functions that must create distinct mutable objects, including async functions. Its thread safety guarantee concerns consistency of the cache data structure. It does not turn a coroutine into a reusable result or combine concurrent requests into one operation.

For a small sequential workflow, store completed values

The following alternative is deliberately limited: one short-lived workflow, sequential callers, a small known set of keys, and no expiration requirement. It stores a value only after a successful await. All examples here use local fixtures.

import asyncio

async def main():
    values = {}
    attempts = {}

    async def fetch(key):
        attempts[key] = attempts.get(key, 0) + 1
        await asyncio.sleep(0)
        if key == "retry" and attempts[key] == 1:
            raise ValueError("temporary fixture failure")
        return {"zero": 0, "none": None, "retry": 7}[key]

    async def load(key):
        if key not in values:
            value = await fetch(key)
            values[key] = value
        return values[key]

    for key, expected in [("zero", 0), ("none", None)]:
        assert await load(key) == expected
        assert await load(key) == expected
        assert attempts[key] == 1
    try:
        await load("retry")
    except ValueError:
        pass
    else:
        raise AssertionError("expected first fetch to fail")
    assert "retry" not in values
    assert await load("retry") == 7
    assert attempts["retry"] == 2
    values.clear()
    assert await load("zero") == 0
    assert attempts["zero"] == 2
    print("falsy values cached; failures retried; clear refetches")

asyncio.run(main())

Use key membership instead of if not values.get(key): zero and None can be valid completed results. Keeping the assignment after the await means a failing fetch does not insert a successful value. A later call can retry; the example does not hide the first exception.

The dictionary has no eviction or TTL. Its safety depends on the stated lifetime and bounded keys. The returned value may also be mutable: callers sharing one cached list can affect each other. Decide whether values are immutable, copied, or intentionally shared before using this pattern with application data.

Concurrent callers need a separate policy

Two callers can both see a missing key, then suspend inside fetch. This value cache can therefore perform duplicate work. Adding a dictionary does not establish single-flight behavior. If that matters, define how requests share in-progress work, what happens to failures, and who owns cancellation.

A Task can be awaited more than once in its event loop, so storing Tasks is a different design from storing coroutine objects. It still requires decisions about cancellation propagation, exception handling, eviction, user or tenant isolation, and loop lifetime. Do not keep a module-level Task cache across unrelated asyncio.run() calls and assume it has the same contract as a plain value cache. An established async cache library also needs review of those policies and your dependency budget.

What clearing the old cache does

Calling cache_clear() removes stored coroutine references; it does not change what the decorator stores on the next call. A new first await may succeed, followed by the same reuse failure. Creating cached coroutines without ever awaiting them can also leave unawaited-coroutine warnings. Remove the incompatible wrapper, then choose whether you need no cache, completed values, or deliberately managed in-progress tasks.

Before adopting a replacement, check the second identical call, valid falsy results, a failed fetch followed by retry, explicit invalidation, and two concurrent callers. The sequential example covers the first four only. It makes no claim about distributed caches, production traffic, or cancellation-safe request coalescing.

Sources & further reading