Use a Python frozendict as a Cache Key—Without the List Trap

A frozendict is created without complaint, then lru_cache throws an unhashable-type error when you pass it in. What inside it is still breaking the cache key?

Use a Python frozendict as a Cache Key—Without the List Trap
TLDR
  • Python 3.15 adds a built-in frozendict that can be a cache argument when all its keys and values are hashable.
  • A nested list remains mutable and prevents the mapping from being hashed.
  • A MappingProxyType reflects changes to its source; it serves a different purpose from capturing fixed mapping entries.

Make the settings argument hashable

A settings dictionary can prevent an otherwise cacheable function from using lru_cache: the cache needs hashable arguments, and a regular dict does not qualify. Python 3.15's built-in frozendict offers a mapping-shaped alternative. Its keys and values must be hashable.

For a report header, a tuple of column-name strings preserves column order while meeting that requirement. This illustrative function caches a result derived entirely from its settings:

from functools import lru_cache

@lru_cache(maxsize=128)
def make_header(options):
    return options["separator"].join(options["columns"])

options = frozendict(columns=("name", "total"), separator=",")
make_header(options)
make_header(options)
print(make_header.cache_info())

The second sequential call can reuse the first result. cache_info() exposes hits and misses; the example keeps up to 128 recent call results.

Construction can succeed while hashing fails

The outer mapping is fixed, but its values retain their own behavior. PEP 814 specifies a shallow copy, so storing a list does not turn that list into an immutable object.

options = frozendict(columns=["name", "total"])
options["columns"].append("tax")  # The list can still change.
hash(options)                   # TypeError: the list is unhashable.

Use value types that match both the data's meaning and the hashability requirement. A tuple of strings works for ordered column names. A tuple containing another list still fails. Also distinguish mapping order from value order: frozendicts preserve insertion order for iteration, but equal key-value pairs compare equal regardless of insertion order. The order inside the column tuple remains significant.

A read-only view follows the original dictionary

MappingProxyType exposes a mapping without allowing writes through the proxy. Changes made to the original dictionary remain visible.

RequirementChoice
Readers should see changes made by the settings owner.A mapping proxy provides a live view.
Keep the key-value associations captured at creation.Create a frozendict.
Pass settings as a cache argument.Use a frozendict with hashable keys and values.

Wrapping an ordinary dict in a proxy does not make it hashable: the proxy delegates hashing to its underlying mapping. Neither approach recursively freezes nested objects.

Check what the function expects to receive

A frozendict is not a dict subclass. A strict isinstance(options, dict) check will reject it. A reader that only requires mapping operations may accept collections.abc.Mapping; a function that mutates its input needs a different design.

Caching must also fit the function's behavior. If every call must perform an external action, equal settings do not justify skipping that action. Start with hashable settings and a function whose earlier result is safe to reuse, then inspect cache hits under the workload that matters.