skip to content

Why use a frozenset rather than a sorted tuple as a cache key for tags?

level: seniorimportance: should knowfreq 33%

answer

  1. What must the key's equality mean?
  2. Order and duplicates: kept or dropped?
  3. Sorting demands comparable elements
  4. Convert once at the function boundary
  5. Never default a parameter to set()

basics

~20 s

Both are hashable keys, but they claim different identities. A frozenset ignores order and collapses duplicates, so it is right when the tags are a true set; a sorted tuple keeps duplicates and requires mutually comparable elements.

solid answer

~50 s

Choose the key whose equality matches the identity you actually want to cache on. A `frozenset` key says *these tags, in any order, ignoring repeats*: `frozenset(['billing', 'billing', 'urgent'])` equals `frozenset(['urgent', 'billing'])`, so a triage bot that has already routed that tag combination gets a hit. A `tuple(sorted(tags))` key keeps duplicates and imposes a total order, so it demands mutually comparable elements — a mix of `str` and `int` raises `TypeError: '<' not supported between instances of 'int' and 'str'` while building the key — and it will miss where the frozenset hits. Both are hashable and both work with `functools.lru_cache`, which hashes its arguments. The trap in practice is at the caller: a helper written as `def route(ticket, tags=set())` hands one shared mutable default straight into the cache and raises `TypeError: unhashable type: 'set'`, so default to `None` and convert with `frozenset(tags)` at the boundary.

code

python · 14 lines
python
from functools import lru_cache

@lru_cache(maxsize=1024)
def route(tags):
    return "finance-queue" if "billing" in tags else "general-queue"

print(route(frozenset(["billing", "billing", "urgent"])))
print(route(frozenset(["urgent", "billing"])))
print(route.cache_info())

try:
    route({"billing"})
except TypeError as exc:
    print(exc)

go deeper

for a junior

Recall that a plain set cannot be a dict key or a cached argument, and that frozenset(tags) is the one-line conversion that makes it one. Also know why tags=set() as a parameter default is a bug.

for a middle

Compare the two keys on meaning: a frozenset drops order and duplicates, a sorted tuple keeps duplicates and demands comparable elements. Be ready to say which distinct inputs end up sharing an entry under each.

for a senior

Demonstrate boundary discipline — convert once where data enters, default to None, and reason about hit rate and correctness together, since the wrong key silently returns another input's answer instead of failing loudly.

for a principal

Own the canonicalization contract across services: what a cache key is claiming, whether it must stay stable across processes and releases, and why a str() of a collection is never that key even when it appears to work.

### The question a cache key answers A cache key is a claim about identity: *these two inputs are the same input, so they may share an answer.* Pick the wrong key and you do not get an error, you get a confidently wrong cached result. So the choice between `frozenset(tags)` and `tuple(sorted(tags))` is a semantic decision before it is a performance one. Concretely: a ticket-triage bot decides a route for each incoming ticket from its tag collection, and the decision is expensive enough that the service caches it to keep the 92nd-percentile response inside its latency budget. Both candidate keys are hashable and both work. They disagree about which tickets share an entry. ### What each key means `frozenset(tags)` says *these tags, in any order, ignoring repeats*. `frozenset(["billing", "billing", "urgent"])` equals `frozenset(["urgent", "billing"])`, so both tickets hit the same entry. It requires only that the elements be hashable, which strings and integers always are. `tuple(sorted(tags))` says *these tags with their multiplicities, canonicalized by sorting*. `("billing", "urgent", "urgent")` and `("billing", "urgent")` are different keys. It also requires the elements be mutually **comparable**, which is a stricter demand than hashable: a tag collection that mixes `str` and `int` raises `TypeError: '<' not supported between instances of 'int' and 'str'` at key-construction time — a failure the frozenset key would never have produced. So: if duplicate tags are meaningless noise, a frozenset key raises your hit rate for free. If a repeated tag carries meaning — a weight, a count, a repeated escalation — the frozenset key silently merges inputs that should not share an answer, and the sorted tuple is correct. ### Wiring it up ```python from functools import lru_cache @lru_cache(maxsize=1024) def route(tags): return "finance-queue" if "billing" in tags else "general-queue" print(route(frozenset(["billing", "billing", "urgent"]))) print(route(frozenset(["urgent", "billing"]))) # cache hit: same key ``` `functools.lru_cache` hashes its arguments to build its own key, so every argument must be hashable — the same constraint a dict key faces, arriving through a different door. Hand it a plain `set` and it raises `TypeError: unhashable type: 'set'`. ### The failure that actually shows up in review The most common way a raw `set` reaches a cache is a mutable default: ```python def route(ticket, tags=set()): # bug ... ``` That default object is created once, when the `def` statement executes, and is shared by every call that omits the argument. It is mutable, so one call's additions leak into the next; and it is unhashable, so the moment it reaches the cache the request fails. Both problems disappear with the same fix: default to `None` and normalize at the top of the function. ```python def route(ticket, tags=None): key = frozenset(tags or ()) ... ``` Converting at the boundary is the whole discipline. Callers keep working with the ergonomic mutable collection; the function immediately takes a frozen snapshot, so later mutation by the caller cannot reach the key or the entry it indexes. ### Keys that look tempting and are not `str(tags)` is the classic wrong answer. It appears to work, and it is wrong twice over: the repr of a set embeds the set's iteration order, which for string elements varies per process, so the same tags produce different keys in different workers; and the string is not injective, so distinct inputs can collide. `id(tags)` is worse, keying on object identity that is reused after garbage collection. If the elements themselves are unhashable — tag objects, dicts of attributes — no built-in conversion saves you. Project each element down to a hashable canonical form first, and be honest that the cache is then keyed on the projection rather than on the original objects. ### One thing the frozen key does not buy Freezing the key protects the index, not the value. If the cached result is a mutable object handed back to every caller, one caller's edit corrupts every subsequent hit. In a shared cache, return an immutable value or a copy — the discipline that makes the key trustworthy has to extend to the other side of the entry.

  • What breaks if the tags arrive as dicts rather than strings?
    Nothing hashes. Both `frozenset` and `tuple` hash their elements, and a dict has no hash, so the key cannot be built at all. You have to project each element down to a hashable canonical form first — a tuple of its sorted items, or a stable identifier — and accept that the cache is then keyed on that projection rather than on the original objects.
  • How would you cache on tags where a repeated tag carries meaning?
    A frozenset is the wrong key, because it collapses repeats and would merge inputs that should not share an answer. Use `tuple(sorted(tags))`, or a frozenset of `(tag, count)` pairs built from a counted mapping. The choice is not stylistic: it defines which distinct inputs share an entry, and getting it wrong returns a confidently wrong cached result instead of an error.
  • Is converting to a frozenset at the boundary enough to make the cache safe?
    For the key, yes — the frozenset is a snapshot, so later mutation of the caller's collection cannot reach it. It says nothing about the cached *value*: hand back a mutable object and one caller's edit corrupts every later hit. In a shared cache, return an immutable value or a copy, so the discipline that makes the key trustworthy extends to the other side of the entry.

saying these in an interview costs you the question

  • Passes a plain set straight in as a dict key
  • Uses str(tags) as the cache key
  • Assumes a sorted tuple and a frozenset hit identically
  • Defaults a parameter to an empty set literal
  • Thinks a frozenset key preserves duplicate tags
  • Sorts a mix of str and int tags without a thought

context