Python's lru_cache Returns Stale Results When Your Function Reads a Mutable Global

I found this one the annoying way: a price that refused to change. I had a small function that multiplied a base price by a rate pulled from a config dict, and I had wrapped it in functools.lru_cache because it was called in a hot loop. Then I changed the rate. The function kept returning the old price, and for a few minutes I was convinced the config reload was broken. It was not. The cache was doing exactly what I told it to do. I just had not thought about what "exactly what I told it" meant.
Here is the whole setup, and it is the kind of code that looks fine on a first read:
from functools import lru_cache
config = {"multiplier": 2, "currency": "USD"}
@lru_cache(maxsize=None)
def price(base):
return base * config["multiplier"]
lru_cache keys on the arguments. price takes one argument, base, and nothing else. The config dict is invisible to the cache. It is not part of the key, it is not checked, and it is not hashed. So when the dict changes, the cache has no reason to care. I ran this to be sure I was not imagining it:
print("first call price(100):", price(100))
print("cache_info:", price.cache_info())
config["multiplier"] = 3
print("multiplier changed to 3 in config dict")
print("second call price(100):", price(100))
print("cache_info:", price.cache_info())
print("fresh arg price(200):", price(200))
price.cache_clear()
print("after cache_clear, price(100):", price(100))
print("cache_info:", price.cache_info())
Verbatim output:
first call price(100): 200
cache_info: CacheInfo(hits=0, misses=1, maxsize=None, currsize=1)
multiplier changed to 3 in config dict
second call price(100): 200
cache_info: CacheInfo(hits=1, misses=1, maxsize=None, currsize=1)
fresh arg price(200): 600
after cache_clear, price(100): 300
Look at the second call. The multiplier is 3 now, 100 times 3 is 300, and the function still said 200. The cache_info line gives it away: hits=1. It never ran the body a second time. It saw price(100), found the old entry, and handed it back. The fresh argument, price(200), did compute, and it used the new multiplier, 600. That is the part that makes this bug so hard to spot in real code. Some calls look right. The ones with new arguments are right. Only the repeated ones are stale, and repeated ones are usually the majority in a hot loop, which is exactly where you put a cache in the first place.
The cache_clear() at the end is the tell. After clearing, price(100) returned 300, the correct value. So the function was fine, the config was fine, and the cache was fine. The combination was the bug.
There are three ways out, and they are not equal. First, put everything the result depends on into the arguments. If the multiplier matters, pass it in: price(base, multiplier). Then a changed multiplier is a different key, and the cache behaves. This is the fix I went with. One extra argument, and the dependency is visible in the signature. Second, call price.cache_clear() whenever the config changes. That works, but now every config writer has to remember to clear a cache it may not know exists, and that is a second bug waiting for a new teammate. Third, drop the cache and compute the value every time. For a multiplication, that is the honest answer. The cache was saving microseconds and costing correctness.
There is a second trap in the same decorator, and it bites from the opposite direction. lru_cache needs hashable arguments, because the arguments become dict keys. Try to cache a function that takes a list and you get this, which I also ran:
6
TypeError: unhashable type: 'list'
total((1, 2, 3)) returned 6. total([1, 2, 3]) raised TypeError. So people convert the list to a tuple first, which works, but then a changed list is a new key, so the cache never hits on it. Same rule either way: the cache only knows what you pass it. Nothing else exists to it.
My rule now is simple. I only put lru_cache on functions whose result is fully decided by their arguments. Pure functions, in other words. The moment a function reads a global, a config dict, the time, a file, or anything else outside its own argument list, the decorator is off the table until I move that input into the arguments. It is a one-line check before I write the decorator, and it would have saved me the afternoon I spent blaming the config reload.
P.S. Everything I write stays free — that part doesn't change. But if this one was worth something to you, you can buy me a coffee ☕ (one-time or monthly, both work). It covers the API credits and hosting behind these experiments — and honestly, it's also the nudge that gets the next one started on the evenings I'd otherwise skip it. — Salam
Comments
Join the discussionNo comments yet. Be the first to comment.







