Skip to content
← All articles

cache and lru_cache: the decorator that deletes your signature

Adding @cache to a hot function is a routine optimisation that silently removes that function from your type checker's coverage. Verified: fib("nope"), fib(1,2,3) and fib() all pass --strict.

functools.cache (3.9) is lru_cache(maxsize=None). Both memoise on the argument tuple. Both are the correct answer for pure, hot, small-domain functions, and both do something to your types that almost nobody knows about.

The signature is gone

Take a fully annotated function and cache it:

from functools import cache

@cache
def fib(n: int) -> int:
    return n if n < 2 else fib(n - 1) + fib(n - 2)

Under mypy --strict, all three of these pass:

fib("nope")
fib(1, 2, 3)
fib()

Only the return type survives. typeshed types the cached wrapper’s call as __call__(*args: Hashable, **kwargs: Hashable) -> _T — it has to, because lru_cache predates ParamSpec and its wrapper genuinely accepts anything hashable at runtime. The parameter types are simply not carried through.

This is a typeshed limitation rather than a mypy bug, so pyright is affected identically. There is no checker configuration that fixes it.

The practical consequence is that decorating a function with @cache quietly removes every call site of that function from static checking. On a widely called helper that is a large hole, and it opens without a diff to any call site and without a single warning.

💡If the parameter types are erased, why does the *return* type click to reveal

survive? Because the return type is carried by the class rather than by the call. lru_cache returns a _lru_cache_wrapper[_T], generic in exactly one parameter — the return type — which is solved from the decorated function. __call__ on that class is then declared to return _T.

Expressing the parameters would need the wrapper to be generic over a ParamSpec as well, and to declare __call__(*args: P.args, **kwargs: P.kwargs). That is expressible today, but it would be a lie in the other direction: the real wrapper additionally requires every argument to be hashable, and there is no way to say “the same signature, but with a Hashable bound on every parameter”.

So typeshed chose the version that never rejects a legal call, at the cost of never rejecting an illegal one. Knowing why is what tells you the fix has to live in your code, not in a future stub release.

The other three hazards

Unhashable arguments raise at call time. f([1, 2]) on a cached function is TypeError: unhashable type: 'list', thrown from inside the decorator with a traceback that points at functools. The checker cannot see it — a list argument is not even an error under the erased signature.

@cache is unbounded by design. Every distinct argument tuple is retained for the life of the process. That is correct for fib; it is a leak for anything keyed on user input, request IDs, or file paths. Use lru_cache(maxsize=N) when the domain is not small and closed.

A cached mutable return is shared. If the function returns a list or a dict, every caller gets the same object, and one caller’s .append is visible to all the others. Cache immutable returns, or return a copy — and be aware that the copy costs you most of what the cache saved.

The pattern that fixes all of it

Keep the cached function private and unvalidated, and expose a public wrapper that types and validates the boundary:

@cache
def _distance(a: str, b: str) -> int:
    ...

def edit_distance(a: object, b: object) -> int:
    if not isinstance(a, str) or not isinstance(b, str):
        raise TypeError("edit_distance() takes two str arguments")
    return _distance(a, b)

The public function has a real signature that mypy checks at every call site. The isinstance guard converts an unhashable or wrong-typed argument into an error that names your function. And the private name signals that the cache’s unbounded growth is a decision made in one place.

💡edit_distance takes object, not str. Doesn't that throw click to reveal

away the static checking you just said you wanted? It looks that way and it does not, because object here is deliberate: this function is a boundary. Its job is to accept whatever the outside world sends and either narrow it or reject it. Inside, _distance(a, b) is called with values mypy has proven to be str, so the interesting part of the program stays fully checked.

If the caller is your own code and the values are already known to be strings, annotate the parameters str instead — then the checker rejects bad calls statically and the isinstance guard becomes defence in depth for the untyped edges (JSON, a web form, a **kwargs splat).

The general rule: object at the boundary plus narrowing, precise types everywhere behind it. Where you must not use object is in the middle of a call chain, where it just propagates ignorance.