functools: lru_cache, partial, reduce

Reviewed & published by Brayan K

The functools module is a standard-library toolkit of higher-order helpers that transform or extend functions — adding caching, pre-filling arguments, folding sequences, and preserving metadata.

Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

These are the tools that separate "code that works" from "code that's fast and clean." A single @lru_cache line can turn an exponential algorithm into an instant one.

What You'll Learn in This Lesson

1 Memoization with @lru_cache and @cache

The naive recursive Fibonacci re-computes the same values an astronomical number of times. One decorator fixes it by remembering every result:

from functools import lru_cache

@lru_cache(maxsize=None)   # unbounded cache
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(35))             # 9227465  (instant, not seconds)
print(fib.cache_info())    # CacheInfo(hits=33, misses=36, maxsize=None, currsize=36)

Without the cache, fib(35) makes nearly 30 million calls. With it, each n is computed exactly once. The .cache_info() method reports hits, misses, and the current size. Since Python 3.9, @cache is a cleaner alias for @lru_cache(maxsize=None):

from functools import cache

@cache                     # same as @lru_cache(maxsize=None)
def slow_square(n):
    print(f"computing {n}...")
    return n * n

print(slow_square(4))      # computing 4... then 16
print(slow_square(4))      # 16  (no "computing" — served from cache)
from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

# Cheap to compute even for large n thanks to caching
print(fib(10))             # 55
print(fib(20))             # 6765
print(fib(30))             # 832040

info = fib.cache_info()
print("hits:", info.hits) # hits: 30

# ✅ Expected output:
# 55
# 6765
# 832040
# hits: 30

2 Pre-Filling Arguments with partial

functools.partial takes a function plus some arguments and hands back a new, specialized function with those arguments locked in. It is cleaner than a lambda and carries no surprise scope bugs:

from functools import partial

# int(x, base) parses a string in a given base.
# Lock base=2 to make a binary parser:
parse_binary = partial(int, base=2)
print(parse_binary("1010"))   # 10
print(parse_binary("1111"))   # 15

# Lock the first argument of a 2-arg function:
def power(base, exp):
    return base ** exp

square = partial(power, exp=2)
cube = partial(power, exp=3)
print(square(5))              # 25
print(cube(2))               # 8

partial shines when you need to pass a configured callback to something like map(), a button handler, or a sort key — you want a function "shape" with some arguments already decided.

from functools import partial

def greet(greeting, name):
    return f"{greeting}, {name}!"

# Build specialized greeters by locking the first argument
say_hello = partial(greet, "Hello")
say_hi = partial(greet, "Hi")

print(say_hello("Ada"))       # Hello, Ada!
print(say_hi("Ben"))          # Hi, Ben!

# Use partial as a clean key/callback
names = ["ada", "ben", "cleo"]
print([say_hello(n.title()) for n in names])

# ✅ Expected output:
# Hello, Ada!
# Hi, Ben!
# ['Hello, Ada!', 'Hello, Ben!', 'Hello, Cleo!']

3 reduce, @wraps, and @cached_property

reduce folds a whole sequence down to a single value by repeatedly applying a two-argument function. Often a plain loop or sum() is clearer, but for running products or custom accumulations it is the right tool:

from functools import reduce

# Multiply every number together (no built-in for this pre-3.8)
product = reduce(lambda acc, x: acc * x, [1, 2, 3, 4, 5])
print(product)               # 120

# With an explicit starting value
total = reduce(lambda acc, x: acc + x, [10, 20, 30], 100)
print(total)                 # 160

Next, @wraps — the fix for decorators that "eat" a function's identity. Without it, the wrapped function reports the wrapper's name:

from functools import wraps

def announce(func):
    @wraps(func)             # copy name, docstring, metadata across
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@announce
def add(a, b):
    "Add two numbers."
    return a + b

print(add(2, 3))             # calling add  then  5
print(add.__name__)          # add   (not "wrapper", thanks to @wraps)

Finally, @cached_property turns an expensive computed attribute into one that runs once and then caches the value on the instance:

from functools import cached_property

class Dataset:
    def __init__(self, values):
        self.values = values

    @cached_property
    def total(self):
        print("summing once...")
        return sum(self.values)

d = Dataset([1, 2, 3, 4])
print(d.total)               # summing once... then 10
print(d.total)               # 10  (cached — no recompute)
from functools import reduce, wraps

# reduce: running product
nums = [2, 3, 4]
print("product:", reduce(lambda a, x: a * x, nums))   # product: 24

# @wraps keeps the real function name
def log_call(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f"{func.__name__} -> {result}")
        return result
    return wrapper

@log_call
def double(n):
    return n * 2

double(21)
print("name is:", double.__name__)

# ✅ Expected output:
# product: 24
# double -> 42
# name is: double

🎯 Your Turn: Fill in the Blanks

Replace each ___ so the program memoizes a factorial and builds a pre-filled helper. Run it and match the expected output.

# 🎯 YOUR TURN: complete the functools program
from functools import lru_cache, partial

# 1. Add the decorator that caches results (unbounded):
@___(maxsize=None)
def fact(n):
    return 1 if n <= 1 else n * fact(n - 1)

print(fact(5))               # 120

# 2. Build a base-2 parser by locking int's base argument:
to_binary_value = partial(int, ___=2)
print(to_binary_value("101"))  # 5

# Expected output:
# 120
# 5

Line 1 blank: lru_cache — so the decorator reads @lru_cache(maxsize=None).

Line 2 blank: base — so the call reads partial(int, base=2).

Common Errors to Avoid

❌ Caching a function that takes a list

@lru_cache
def total(items):     # items is a list -> unhashable!
    return sum(items)
total([1, 2, 3])      # TypeError: unhashable type: 'list'

✅ Pass a tuple instead — tuples are hashable: total((1, 2, 3)).

❌ Forgetting @wraps in a decorator

def deco(func):
    def wrapper(*a, **k):
        return func(*a, **k)
    return wrapper        # wrapper.__name__ leaks everywhere

✅ Add @wraps(func) above wrapper to keep the real name and docstring.

@cache
def now():
    return datetime.now()  # frozen at first call forever!

✅ Only cache functions whose output depends solely on their arguments.

🎯 Mini Challenge: Cached Path Counter

Count the number of paths through a grid from the top-left to the bottom-right, moving only right or down. The naive recursion is exponential — cache it and it is instant.

from functools import cache

@cache
def paths(rows, cols):
    # One row or one column: exactly one straight path
    if rows == 1 or cols == 1:
        return 1
    return paths(rows - 1, cols) + paths(rows, cols - 1)

print(paths(3, 3))           # 6
print(paths(5, 5))           # 70
print(paths(10, 10))         # 48620
print("cache size:", paths.cache_info().currsize)

# ✅ Expected output:
# 6
# 70
# 48620
# cache size: 99

📚 Keep Learning

Go deeper with the official Python documentation:

Frequently Asked Questions

Lesson complete — your functions are faster and cleaner!

You can memoize with @lru_cache and @cache, pre-fill arguments with partial, fold sequences with reduce, preserve metadata with @wraps, and cache computed attributes with @cached_property.

Practice quiz

What does @lru_cache do to a function?

  • Runs it on a background thread
  • Limits how many times it can be called
  • Memoizes results so repeated calls with the same arguments are instant
  • Makes it run in parallel

Answer: Memoizes results so repeated calls with the same arguments are instant. @lru_cache stores each result keyed by its arguments, so later calls with the same arguments are served from the cache.

What is @cache equivalent to since Python 3.9?

  • @lru_cache(maxsize=None)
  • @lru_cache(maxsize=128)
  • @property
  • @wraps

Answer: @lru_cache(maxsize=None). @cache is a cleaner alias for @lru_cache(maxsize=None) — an unbounded cache.

What does partial(int, base=2) produce?

  • The number 2
  • A function that always returns 2
  • An error
  • A function that parses binary strings

Answer: A function that parses binary strings. partial locks base=2 into int, giving a binary parser: parse_binary("1010") returns 10.

What does reduce(lambda acc, x: acc * x, [1, 2, 3, 4, 5]) return?

  • 15
  • 120
  • 5
  • [1, 2, 6, 24, 120]

Answer: 120. reduce folds the list by multiplying: 1*2*3*4*5 = 120.

What does reduce(lambda acc, x: acc + x, [10, 20, 30], 100) return?

  • 160
  • 60
  • 100
  • 150

Answer: 160. The third argument 100 is the starting value: 100 + 10 + 20 + 30 = 160.

Why use @functools.wraps in a decorator?

  • To make the wrapper run faster
  • To cache the function's result
  • To copy the original function's name, docstring, and metadata onto the wrapper
  • To make the function private

Answer: To copy the original function's name, docstring, and metadata onto the wrapper. Without @wraps the wrapped function reports the wrapper's name; @wraps copies the original metadata across.

What kind of argument can NOT be passed to an @lru_cache'd function?

  • An integer
  • A list (unhashable)
  • A string
  • A tuple

Answer: A list (unhashable). Cache keys must be hashable; a list is unhashable and raises TypeError. Use a tuple instead.

What does @cached_property do?

  • Recomputes the value on every access
  • Makes the attribute read-only forever
  • Caches the value globally for all instances
  • Computes the value once and caches it on the instance

Answer: Computes the value once and caches it on the instance. @cached_property runs the computation once and stores the result on the instance's __dict__.

Which type of function is safe to cache?

  • One that reads the current time
  • A pure function whose output depends only on its arguments
  • One that hits a database
  • One that reads a file each call

Answer: A pure function whose output depends only on its arguments. Only pure functions (same output for same input, no side effects) should be cached.

After @announce wraps add, what does add.__name__ print if @wraps was used?

  • wrapper
  • announce
  • add
  • func

Answer: add. With @wraps(func) the wrapper keeps the original name, so add.__name__ is "add".

Continue this course

Frequently asked questions

What does @lru_cache actually do?

@lru_cache wraps a function in a memory cache. The first time you call it with a particular set of arguments, it runs the function and stores the result keyed by those arguments. Every later call with the same arguments returns the stored value instantly without re-running the body. LRU means 'least recently used' — when the cache hits its size limit, the oldest entries are evicted. It is the simplest way to add memoization to a pure function in Python.

What is the difference between @lru_cache and @cache?

@cache (added in Python 3.9) is just @lru_cache(maxsize=None) — an unbounded cache that never evicts anything. Use @cache when the set of possible inputs is small and you want maximum speed. Use @lru_cache(maxsize=128) when inputs are unbounded and you want to limit how much memory the cache can use. Both require the function's arguments to be hashable.

What is functools.partial used for?

partial() takes a function and some arguments, and returns a new function with those arguments already filled in. If you have int(x, base) and you call partial(int, base=2), you get a new function that parses binary strings. It is a clean way to make specialized versions of general functions without writing a wrapper or a lambda.

Why do my decorators lose the function name, and what fixes it?

When you wrap a function in a decorator, the returned wrapper replaces the original — so func.__name__ becomes 'wrapper' and the docstring disappears. @functools.wraps(func) copies the original function's name, docstring, and metadata onto the wrapper, so tools, debuggers, and help() still see the real function. Always put @wraps on the inner function of a decorator.