What is Python's lru_cache and when to use it?

January 28, 2026 | ⏱ 13 min read

Have you ever come across scenarios where your Python code is repeatedly calling functions that return values which are completely determined by their input parameters?

If such functions are lightweight - i.e., they only do very simple calculations - we don’t have to worry too much about them. But what if there is a frequently called function doing some heavy, deterministic calculation - meaning its output is completely determined by the input parameters?

Well, Python has a nice little (yet powerful) built-in function decorator that you can easily use in your code without having to install any additional dependencies. And it’s thread-safe as well!

from functools import lru_cache — to the rescue!

lru_cache is one of Python’s most useful decorators for performance optimization. With the right knowledge of how to use it, lru_cache can greatly speed up your code!

However, there are some considerations you need to keep in mind before you start using it. In this article, I am going to walk you through when and how to use lru_cache, and also when not to use it, with examples. By the end of this post, you’ll be confident using it to optimize your Python code.

The API

@functools.lru_cache(maxsize=128, typed=False)

  • maxsize - The maximum number of recent calls to save in the cache. The default is 128, and when maxsize is set to None, the LRU feature is disabled and the cache can grow indefinitely, without a bound.
  • typed - The default is False. This determines whether to cache function arguments with different types separately (ex: when typed=True, the input parameters 3.0 and 3 are considered different, resulting in two cache items).

LRU = Least Recently Used

lru_cache is a function that caches (also known as memoizes) function results along with their input parameters. Under the hood, it is a dictionary that stores:

  • Key: function arguments as a tuple
  • Value: function result

Sounds simple, right?

So where does the least recently used part come from? Well, like everything else, this cache can also have a size, which we can define at declaration using the maxsize parameter (we can also have an unlimited cache size by setting maxsize=None). However, when the cache is full, it removes the least recently used items from it - hence the name least recently used (LRU) cache.

The problem of expensive repeated calculations

Take a look at the following example, which calculates the Fibonacci sequence.

def fibonacci(n):
    """Calculate nth Fibonacci number - SLOW!"""
    print(f"Computing fib({n})")
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

Now, if I call result = fibonacci(10), it will be painfully slow! If you look closely, here’s what happens under the hood:

Computing fib(10)
Computing fib(9)
Computing fib(8)
Computing fib(7)
Computing fib(6)
Computing fib(5)
Computing fib(4)
Computing fib(3)
Computing fib(2)
Computing fib(1)
Computing fib(0)
Computing fib(1) # Computed again!
Computing fib(2) # Computed again!
Computing fib(1) # Computed again!
Computing fib(0) # Computed again!
Computing fib(3) # Computed again!
Computing fib(2) # Computed again!
Computing fib(1) # Computed again!
...

You can see that a lot of repeated calculations are done, which is indeed a waste of computing resources. Imagine calling fibonacci(35) - that would make a staggering 29 million function calls! Energy bills are quite high nowadays, and the same function could do much better by simply being decorated with our hero, lru_cache!

Decorating with @lru_cache

In the code below, nothing has changed except that the same function above is now decorated with @lru_cache, with a maximum cache size of 128.

from functools import lru_cache

@lru_cache(maxsize=128)
def fibonacci(n):
    """Calculate nth Fibonacci number - FAST!"""
    print(f"Computing fib({n})")
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

If we call the lru_cache-decorated fibonacci function now, as result = fibonacci(10), the following happens:

Computing fib(10)
Computing fib(9)
Computing fib(8)
Computing fib(7)
Computing fib(6)
Computing fib(5)
Computing fib(4)
Computing fib(3)
Computing fib(2)
Computing fib(1)
Computing fib(0)

You’ll notice a significant drop in the number of actual function invocations above, since for all the subsequent duplicate function invocations, the results are taken directly from the LRU cache, saving an awful lot of time and energy! Still not impressed? Let’s do a timing comparison for the above function.

import time
from functools import lru_cache

# Without cache
def fib_slow(n):
    if n < 2:
        return n
    return fib_slow(n-1) + fib_slow(n-2)

# With LRU cache
@lru_cache(maxsize=None)
def fib_fast(n):
    if n < 2:
        return n
    return fib_fast(n-1) + fib_fast(n-2)

# Test with n=45
print("Without cache:")
start = time.time()
result = fib_slow(45)
print(f"Result: {result}, Time: {time.time() - start:.10f}s")
# Result: 1134903170, Time: 153.0848743916s

print("\nWith cache:")
start = time.time()
result = fib_fast(45)
print(f"Result: {result}, Time: {time.time() - start:.10f}s")
# Result: 1134903170, Time: 0.0000000000s  <-- that was lightning fast!!

Did you see the results I got from my machine? The function without the cache took around 153 seconds to complete, whereas the same function, for the same calculation, ran instantaneously with lru_cache to get the same result! Isn’t that fantastic?

I hope you can imagine the amount of performance gain we can reap from this awesome function decorator.

Basic usage

Given below are some examples of how you could use lru_cache.

from functools import lru_cache

# Default: maxsize=128
@lru_cache
def my_function(x):
    return x * 2

# Custom cache size
@lru_cache(maxsize=256)
def bigger_cache(x):
    return x * 2

# Unlimited cache (no eviction)
@lru_cache(maxsize=None)
def unlimited_cache(x):
    return x * 2

# Here the cache size is 100, and the type of parameters is considered for caching
# ex: my_typed_cache(3) and my_typed_cache(3.0) would be cached separately
@lru_cache(maxsize=100, typed=True)
def my_typed_cache(x):
    return x * 2

Cache statistics

Like every cache, lru_cache also has statistics associated with it to represent:

  • hits: Times results were returned from the cache
  • misses: Times the function had to perform the computation (real function invocations)
  • maxsize: Maximum size of the cache
  • currsize: Current number of cached items

Let’s have a look at the example below.

from functools import lru_cache

@lru_cache(maxsize=20, typed=True)
def my_super_complex_function(a, b):
    print(f"my_super_complex_function was called! a={a}, b={b}")
    return a+b

Now let’s call this function a couple of times.

# Let's use above function a couple of times
my_super_complex_function(1, 2) # my_super_complex_function was called! a=1, b=2
my_super_complex_function(1.0, 2.0) # my_super_complex_function was called! a=1.0, b=2.0

If we check the cache statistics now, you may notice the current size of the cache is 2 rather than 1, because this is a typed cache, causing it to treat (1, 2) and (1.0, 2.0) as different parameters. In addition, both of the above calls are cache misses (misses=2), and hence hits=0.

print(my_super_complex_function.cache_info()) # CacheInfo(hits=0, misses=2, maxsize=20, currsize=2) 

Now, if we want to hit the cache, we need to pass parameters equal to something we’ve provided before.

res = my_super_complex_function(1.0, 2.0) # Here you would not see the print statement from the my_super_complex_function
print(f"The result is {res}") # The result is 3.0

If we check the cache statistics now, you’ll see it has one hit, and that’s where we’ve taken the above result from.

print(my_super_complex_function.cache_info()) # CacheInfo(hits=1, misses=2, maxsize=20, currsize=2)

Clearing the cache

It’s really easy to clear the cache. Example:

my_super_complex_function.clear()
print(my_super_complex_function.cache_info()) # 

When to use lru_cache

lru_cache is a perfect candidate for the following problems.

1- Recursive functions with overlapping subproblems, ex:

  • In the above examples, the problem of fibonacci(n) is split into fibonacci(n-1) and fibonacci(n-2)
  • Factorial numbers, ex: factorial(5) = 5 * factorial(4)
    @lru_cache(maxsize=None)
    def factorial(n):
      if n <= 1:
          return 1
      return n * factorial(n-1)
    
  • Pascal’s triangle
    @lru_cache(maxsize=None)
    def pascal_triangle(row, col):
      if col == 0 or col == row:
          return 1
      return pascal_triangle(row-1, col-1) + pascal_triangle(row-1, col)
    

2- API calls or database queries requesting the same static data.

import requests

@lru_cache(maxsize=100)
def fetch_weather(city):
    """Cache weather data for 100 cities"""
    response = requests.get(f"https://api.weather.com/{city}")
    return response.json()

# First call hits API
weather = fetch_weather("London")  # API call

# Subsequent calls use cache
weather = fetch_weather("London")  # From cache!

3- Mathematical functions that perform deterministic calculations.

import math

@lru_cache(maxsize=None)
def is_prime(n):
    """Check if number is prime"""
    if n < 2:
        return False
    for i in range(2, int(math.sqrt(n)) + 1):
        if n % i == 0:
            return False
    return True

# Filter prime numbers from a list
numbers = list(range(1000))
primes = [n for n in numbers if is_prime(n)]
# Each is_prime call is cached

4- Computations with Python’s property members. property() is Python’s built-in function for enhancing encapsulation and better controlling access to class attributes. To read more on that, I’ve found this article useful.

class DataProcessor:
    def __init__(self, data):
        self.data = data
    
    @property
    @lru_cache(maxsize=1)
    def total(self):
        """Expensive computation cached as property"""
        print("Computing total...")
        return sum(self.data)

processor = DataProcessor([1, 2, 3, 4, 5])
print(processor.total)  # Computing total... 15
print(processor.total)  # 15 (from cache)

When not to use lru_cache

Knowing when not to use lru_cache is essential to using this function properly in applications, without causing hideous bugs. Given below are some examples.

1- When the arguments are unhashable.
lru_cache requires the function’s input parameters to be hashable, and it will raise errors otherwise. Examples of hashable data types are tuple, string, numbers (int, float), bool, frozenset, and NoneType. Examples of unhashable data types are dict, list, set, and bytearray.

@lru_cache
def process_data(data_dict):  # This won't work and would give Error: unhashable type: 'dict'
    return sum(data_dict.values())

To read more about this, I’ve found this article useful.

2- When functions have side effects.

@lru_cache  # This will be a bad idea!
def send_email(to_address, message):
    # Side effect: sends actual email
    email_service.send(to_address, message)

# If called twice, only first call sends email!
send_email("user@example.com", "Hello")  # Sends email
send_email("user@example.com", "Hello")  # Does nothing! (cached)

3- Functions using or returning time-sensitive data.
If your function includes any time-sensitive information associated with the result, do not use lru_cache, as it will return cached results instead.

import time

@lru_cache  # Returns stale data!
def get_current_time():
    return time.time()

print(get_current_time())  # 1234567890.123
time.sleep(5)
print(get_current_time())  # 1234567890.123 (same! wrong!)

4- Functions with large return values.
Since results from the function are cached in memory, avoid using it if the function returns large values that consume a lot of memory.

@lru_cache(maxsize=1000)  # This could consume GB of RAM!
def load_large_image(filename):
    # Returns 10MB image
    return open(filename, 'rb').read()

# With 1000 cached images = 10GB of memory!

Best practices

It is always advisable to thoroughly analyse your candidate function before decorating it with @lru_cache, to ensure it does not have any of the above undesirable properties. In addition, if such a function contains any logging or debug statements, you’ll only see them on the first use, since consecutive calls don’t actually invoke the original function - instead, the result is retrieved from the cache. The size of the cache also needs to be fine-tuned for optimum performance, since maxsize=None or higher values will cost you in the form of large RAM utilization. It is therefore recommended to start with the default maxsize=128, then monitor its utilization with cache_info().

Remember - lru_cache trades memory for speed!

Thank you!

I hope you’ve learned something new from this blog post that you can apply in your daily coding to improve the performance of your projects. I wish you good luck with lru_cache. Happy coding!