It's common to see @lru_cache used for quick singletons in Python. As you might guess from the name — that's not the intended purpose of the function. But that doesn't necessarily mean this opportunistic "trick" is bad. Just that there are subtle differences between a classic singleton and the least recently used cache.
Using @lru_cache for singletons has a number of benefits.
- Instantiation is lazy, meaning you don't waste resources creating an instance that isn't used.
- Less boilerplate, more readable. A simple decorator is all you need, rather than a verbose class.
But it also has important drawbacks, especially when instantiation is slow. This is for two key reasons.
1. Performance
If instantiation is not instant, lazily creating your singleton can cause unnecessary delays. For example, in a FastAPI app, it isn't ideal if your first request is burdened with multiple long-running instantiations. These could be eagerly created during application startup.
2. Exactly-once guarantee
The @lru_cache decorator is explicitly documented as thread-safe in the sense that its internal state will not be corrupted under concurrent access. But this does not guarantee a singleton factory or an "initialise-exactly-once" lifecycle contract. If the function is called twice with the same value before the value is computed and cached you could end up with multiple distinct objects created for the same key. In other words: not a singleton.
The risk of this race condition is generally negligible. But the risk is greater for long-running instantiations.
So, when is it definitely bad to use @lru_cache as a singleton?
Multiprocessing, where the cached object itself spawns worker processes, is a good example (e.g., ProcessPoolExecutor). In this case, instantiation requires due-diligence and careful lifecycle management.
If the process pool is created lazily, the first request will bear the full brunt of spawning — spiking latency. Also, if excess pools instantiate excess child processes, this can lead to unpredictable behaviour or unnecessary CPU and memory usage. The long-running nature of spawning not only compounds the latency issue, but increases the risk of multiple pools being created.
Another consideration is teardown. While a classic singleton pattern will gracefully close clients, @lru_cache does not include a hook to terminate deterministically. Again, not always a problem — but generally not ideal for multiprocessing.
With this in mind, is it ever ok to use @lru_cache for singletons?
I would argue yes. But with an important caveat:
You must accept that multiple distinct instances could be created in edge cases.
In many cases this is acceptable. For example, an unused database client is not going to hurt anyone. But in certain cases this can lead to subtle, yet dangerous, behaviour.
A good rule of thumb:
- Use
@lru_cache(maxsize=1)for lightweight objects where extra instances are harmless. - Use lifespan (or classic singleton patterns) for anything with a real lifecycle, requiring controlled startup and teardown.
A purist might argue that using lru_cache is not the right choice for lifecycle management. But in the words of The Zen of Python:
"Special cases aren't special enough to break the rules. Although practicality beats purity."
Top comments (0)