Why use a frozenset rather than a sorted tuple as a cache key for tags?
answer
- What must the key's equality mean?
- Order and duplicates: kept or dropped?
- Sorting demands comparable elements
- Convert once at the function boundary
- Never default a parameter to set()
basics
~20 sBoth 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 sChoose 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 linesfrom 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
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.
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.
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.
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