skip to content

The functools Toolkit

The standard library's kit for building functions out of other functions: pre-binding arguments, memoizing results, dispatching on type. Each tool has a crisp "when would you reach for this" answer.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

17

What does functools.lru_cache do to a function, and how does functools.cache differ?

level: juniorimportance: must knowfreq 70%

answer

  1. A decorator that remembers past calls
  2. Arguments in, stored result out
  3. A cap, and the least recent goes first
  4. cache means maxsize=None
  5. cache_info() reports hits and misses

basics

~20 s

functools.lru_cache stores each call's result in a dictionary keyed by the arguments, so a repeat call returns the stored value without running the body. functools.cache, added in 3.9, is lru_cache(maxsize=None): unbounded, and it never evicts.

solid answer

~40 s

`@functools.lru_cache` replaces a function with a wrapper holding a dictionary from the call's arguments to that call's result. The first call runs the body and stores the value; later calls with the same arguments return the stored value. `maxsize` caps the number of entries, and when the table is full the least recently used entry is discarded. `functools.cache`, added in 3.9, is exactly `lru_cache(maxsize=None)`: unbounded, no eviction, and marginally faster because it skips the recency bookkeeping. Both wrappers expose `cache_info()`, which reports hits, misses, the configured maxsize and the current size, and `cache_clear()`, which drops everything. Memoization is only correct for a function that is deterministic in the arguments it is keyed on and free of side effects worth repeating: the stored value never notices that the world changed underneath it.

code

python · 17 lines
python
import functools

calls = []

@functools.lru_cache(maxsize=2)
def slow_square(n):
    calls.append(n)
    return n * n

slow_square(2)
slow_square(3)
slow_square(2)
print(calls)                  # [2, 3] - the third call was a hit
print(slow_square.cache_info())

slow_square(4)                # table is full, 3 was least recently used
print(slow_square(3), calls)  # 9 [2, 3, 4, 3] - 3 had to be recomputed

go deeper

for a junior

Be ready to define memoization in one sentence and to name the decorator that gives it to you for free. Knowing that functools.cache is the unbounded variant of functools.lru_cache is enough at this level.

for a middle

Explain how the key is built, what maxsize caps and which entry eviction picks, and show yourself reading cache_info() to judge whether the cache is earning the memory it holds.

for a senior

An interviewer expects you to say which functions are safe to memoize in a long-lived process - deterministic, cheap to key, bounded argument space - and how you would bound or clear the cache in production.

for a principal

Own whether an in-process cache belongs in the design at all, weighed against recomputation, precomputation at startup, or a cache other processes can see and invalidate.

## What the decorator actually builds `functools.lru_cache` is a decorator factory. Applied to a function, it returns a wrapper object that owns a dictionary. Each call builds a key from the arguments, looks the key up, and either returns the stored result (a *hit*) or calls the original function, stores the result and returns it (a *miss*). The original function is untouched and still reachable through the wrapper, which is how documentation and testing tools recover the real signature. There are two forms. `@functools.lru_cache` applied bare uses the default `maxsize=128`; since 3.8 the bare form is legal, and before that the parentheses were mandatory. `@functools.lru_cache(maxsize=1024)` sets the cap explicitly. `maxsize=None` removes the cap entirely, and `functools.cache`, added in 3.9, is a name for exactly that: `cache` and `lru_cache(maxsize=None)` are the same behaviour, with `cache` reading better at the call site. ## What LRU means here LRU is *least recently used*. The wrapper tracks the order in which keys were touched. When the table already holds `maxsize` entries and a new key arrives, the entry that has gone longest without being read is discarded to make room. That is the only reason an entry ever leaves the table: there is no timeout, no size-in-bytes budget, no pressure signal from the allocator, and no notification when the data the function read has changed. An entry that is read often survives forever; an entry that is never read again is evicted only once something else needs its slot. With `maxsize=None` there is no recency bookkeeping to do at all, which is why `cache` is slightly cheaper per call than a bounded cache — and why it can only ever grow. ## Reading the counters Both wrappers expose `cache_info()`, returning a named tuple with four fields: `hits`, `misses`, `maxsize` and `currsize`. That tuple is the whole observability story for this decorator, and it is worth reading in a running process rather than once in a REPL. A hit ratio near zero says the arguments essentially never repeat, so the cache is buying nothing and paying memory for the privilege. A high ratio with `currsize` pinned at `maxsize` says the working set is larger than the cap and raising it may pay. `cache_clear()` empties the table and resets the counters; it is the only invalidation the decorator offers. ## When memoization is correct The cache answers from the past, so the function must be one whose past answers stay right. Three conditions matter. First, determinism in the keyed arguments: given the same arguments the function must produce the same result, which rules out anything reading a clock, a random source, a mutable global or a database. Second, side effects: if the body writes a log line, increments a counter or sends a message, memoizing silently drops those effects on every hit. Third, a bounded space of distinct arguments, or a `maxsize` chosen deliberately, because every distinct key that arrives is one more entry held by strong reference — both the arguments and the result stay alive as long as the entry does. ## Where it fits The decorator's virtue is that it is one line and needs no plumbing, which makes it the right reach for a pure, cheap-to-key computation whose inputs repeat: parsing a configuration string, resolving a lookup table, normalizing a small value in a hot path. Its vice is that it is invisible from the call site, process-local and un-invalidatable except wholesale. That is a fair trade for a pure helper and a poor one for anything that reads changing state. A quick mental rule: reach for `functools.cache` when the set of possible arguments is small and fixed, reach for `functools.lru_cache(maxsize=N)` when the arguments repeat but the space is open-ended, and reach for neither when the answer can go stale. ## One more property worth knowing The wrapper's own bookkeeping is safe to use from several threads, but it does not serialize the wrapped call. If two threads miss on the same key at the same moment, both run the function and one result simply overwrites the other. That is harmless for a pure computation and wrong if you were counting on the work happening exactly once.

  • What does cache_info() return, and how would you use it to choose a maxsize?
    A named tuple of hits, misses, maxsize and currsize. Read it from a running process, not once at startup. A hit ratio near zero means the arguments rarely repeat and the cache is pure cost. A high ratio with currsize sitting at maxsize means the working set exceeds the cap, so raising it may pay. A currsize that never approaches maxsize means the cap is not the constraint and the memory is bounded by the key space instead.
  • When is functools.cache the wrong choice even though the function is expensive?
    When the result can go stale, when the variety of arguments is unbounded, or when the arguments and results are large objects the cache will then keep alive indefinitely. It never evicts and holds strong references, so an open-ended key space turns it into steady memory growth. A bounded lru_cache with a deliberate maxsize, or an explicit cache with expiry, fits those cases better.
  • Is the undecorated function still reachable after applying functools.lru_cache?
    Yes. The wrapper copies the original function's metadata and keeps a reference to the original callable, so tests can call the uncached version deliberately and introspection tools can report the real signature rather than the wrapper's. That reference is also why the decorator does not lose the docstring or the qualified name.

It is a receptionist keeping a notepad of answers she has already looked up: the same question gets the same answer with no second trip to the archive, and the notepad never learns that the archive was updated.

saying these in an interview costs you the question

  • Says functools.cache and functools.lru_cache are unrelated tools
  • Thinks cached entries expire on a timer
  • Believes the cache notices when the underlying data changes
  • Claims maxsize=None still evicts under memory pressure
  • Memoizes a function whose side effects matter

context

open as a page

Why does functools.lru_cache raise TypeError when the function is called with a list?

level: middleimportance: must knowfreq 55%

basics

~20 s

The wrapper builds a dictionary key out of the call's arguments, so every argument must be hashable. A list is not, so the lookup raises TypeError before the function body ever runs. Pass a tuple, or a frozenset for set-like input.

open as a page

Why should the __lt__ you pair with functools.total_ordering return NotImplemented?

level: middleimportance: must knowfreq 35%

basics

~10 s

Returning NotImplemented for an operand type you do not handle lets Python try the other operand's reflected method and then raise a clear TypeError. Returning False instead answers a question you cannot answer, silently.

open as a page

Why would you use functools.partial instead of a lambda for a callable that must be pickled?

level: middleimportance: must knowfreq 46%

basics

~20 s

pickle stores a function by its importable qualified name, and a lambda has none, so pickling one fails. A functools.partial defines its own reduction — the wrapped callable plus the bound arguments — so it pickles whenever those pieces are themselves picklable.

open as a page

How does functools.singledispatch pick an implementation when several registered classes match the argument?

level: middleimportance: must knowfreq 45%

basics

~10 s

It takes the class of the first positional argument, walks that class's method resolution order, and uses the implementation registered for the nearest ancestor, falling back to object. Registration order is irrelevant; specificity decides.

open as a page

What does @functools.total_ordering derive, and what must the class already define?

level: juniorimportance: should knowfreq 30%

basics

~10 s

functools.total_ordering is a class decorator that fills in the ordering operators you did not write. Define eq plus one of lt, le, gt or ge, and it derives the other three.

open as a page

What does functools.partial return, and how are its stored arguments merged at call time?

level: juniorimportance: should knowfreq 42%

basics

~20 s

functools.partial returns a new callable object that remembers the original callable plus the arguments you pre-bound. Calling it puts the stored positional arguments in front of the ones you pass and merges the stored keywords, which the caller can override.

open as a page

What does @functools.singledispatch do to a function, and how do you register an implementation for a type?

level: juniorimportance: should knowfreq 30%

basics

~20 s

functools.singledispatch turns the decorated function into a generic function. Its original body becomes the fallback implementation registered for object, and the decorator adds a register attribute used to attach one implementation per type of the first positional argument.

open as a page

How does functools.cached_property differ from @property stacked on functools.lru_cache?

level: middleimportance: should knowfreq 45%

basics

~20 s

functools.cached_property computes once per instance and stores the value on that instance, so later reads are ordinary attribute lookups and it dies with the object. A property over functools.lru_cache shares one cache keyed by self, pinning every instance.

open as a page

Why does sorted() need functools.cmp_to_key to use a three-way comparator?

level: middleimportance: should knowfreq 40%

basics

~20 s

Python 3 removed the cmp parameter: sorted() and list.sort() accept only key and reverse. functools.cmp_to_key wraps a two-argument comparator returning a negative number, zero or a positive number into the one-argument key callable they do accept.

open as a page

Why can't @functools.singledispatch dispatch a method's argument, and what does functools.singledispatchmethod do?

level: middleimportance: should knowfreq 28%

basics

~20 s

Inside a class body, singledispatch still dispatches on the first positional argument, which is self, so every call on one class picks the same implementation. functools.singledispatchmethod, added in 3.8, is a descriptor that dispatches on the first argument after self.

open as a page

functools.cache on an image-thumbnail worker's sizing step keeps growing its memory - how do you diagnose and fix that?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Read cache_info() first: currsize tracking the call count while hits stay near zero means the key space is unbounded, so the table only grows. Key on the stable parameters instead of per-image identity, or cap it with functools.lru_cache(maxsize=N).

open as a page

A chat archiver's functools.cmp_to_key comparator returns 0 whenever it hits an error — what breaks?

level: seniorimportance: should knowfreq 25%

basics

~20 s

Swallowing the error and returning 0 tells the sort those two messages are tied. The order silently stops being a consistent total order, so transcripts come out shuffled in a way that depends on input order, and nothing raises.

open as a page

In a result-loading pipeline built on functools.partial callbacks, why can a caller override a pre-bound keyword, and how do you prevent it?

level: seniorimportance: should knowfreq 28%

basics

~20 s

A keyword pre-bound with functools.partial is a default, not a lock: the call's keywords are merged over the stored ones, so the caller wins. Bind the value positionally, or make the parameter positional-only, if it must not be overridable.

open as a page

Where does functools.singledispatch beat an if/elif isinstance chain, and where does it not?

level: seniorimportance: should knowfreq 38%

basics

~20 s

functools.singledispatch wins when the set of handled classes grows from outside the module that defines the operation: each class's owner registers its own implementation, and resolution is by specificity, not branch order. A short closed chain stays a chain.

open as a page

When is functools.lru_cache the wrong caching tool for a long-lived Python service?

level: principalimportance: should knowfreq 40%

basics

~20 s

It is wrong wherever you need expiry, a byte budget, targeted invalidation, per-tenant scoping or a view shared across processes. It offers none of those: one process-local table, entries counted not sized, and cache_clear() as the only eviction you control.

open as a page

How does functools.partialmethod differ from functools.partial in a class body?

level: seniorimportance: nice to knowfreq 16%

basics

~20 s

functools.partialmethod is built for class bodies: it passes the instance as the first argument to the wrapped function, ahead of the pre-bound ones. Since Python 3.14 a plain functools.partial is also a method descriptor, but it inserts the instance after its stored arguments.

open as a page