PYTHON / DICTIONARIES AND SETS
Frozensets and hashability
Use frozenset to put set-like values inside sets and dictionary keys, and explain exactly which objects Python considers hashable and why.
What you will learn
- Build frozensets and use them as dict keys or elements of another set
- Explain why list, set, and dict refuse __hash__ while tuple and frozenset allow it
- Predict which set operations work on a frozenset and which raise AttributeError
- Keep __eq__ and __hash__ consistent when writing your own hashable class
Understanding Frozensets and hashability
A dict or set does not scan its contents looking for a match. It asks the object for hash(obj), uses that number to pick a storage slot, and only then compares candidates with ==. That design has one hard requirement: an object's hash must never change while it is stored. If it changed, the object would still be sitting in the slot chosen by the old hash, and every future lookup would search the new slot and find nothing.
Python enforces this by refusing to give a __hash__ to types whose value can be mutated in place. list, dict, bytearray, and set all raise TypeError: unhashable type when hashed, which is why {1, 2} cannot be a dict key or an element of another set. frozenset is the immutable sibling of set: same elements, same comparison and algebra, but no add, discard, or update, so its hash can be computed once from its elements' hashes and trusted forever. Because that hash combines element hashes with an order-independent operation, frozenset([3, 1]) and frozenset([1, 3]) are equal and hash identically.
Hashability is recursive for containers. A tuple is hashable only when every element is, so (1, [2]) is unhashable, and frozenset([[1]]) fails at construction time because the list must be hashed to be inserted. The same rule explains custom classes: objects are hashable by default using their identity, but the moment you define __eq__ you break the promise that equal objects hash equally, so Python sets __hash__ to None and your instances become unhashable until you define __hash__ yourself.
groups = [{"ana", "ben"}, {"ben", "ana"}, {"cara", "dev"}]
counts = {}
for g in groups:
key = frozenset(g)
counts[key] = counts.get(key, 0) + 1
for key, n in sorted(counts.items(), key=lambda kv: sorted(kv[0])):
print(sorted(key), n)
print(frozenset({"ana", "ben"}) == {"ben", "ana"})
try:
bad = {{"ana"}: 1}
except TypeError as e:
print("TypeError:", e)An object can go in a set or be a dict key only if its hash is fixed for life, which is exactly what frozenset guarantees and set does not.
Worked examples
Order-independent hashing and nesting
Shows that equal frozensets hash the same regardless of insertion order, and that frozensets can be elements of a set.
a = frozenset([3, 1, 2])
b = frozenset([2, 3, 1])
print(a == b, hash(a) == hash(b))
nested = {frozenset([1, 2]), frozenset([2, 1]), frozenset([3])}
print(len(nested))
try:
frozenset([[1], [2]])
except TypeError as e:
print("TypeError:", e)Example explained
Line 1a == b is True because sets compare by membership, not by the order elements were added.
Line 2hash(a) == hash(b) must hold for equal objects, so frozenset combines element hashes with an order-independent operation.
Line 3The nested set has length 2: the two equal frozensets collapse into one element.
Line 4frozenset([[1], [2]]) fails during construction, because each list has to be hashed before it can be stored.
Defining __eq__ removes __hash__
Demonstrates that a class with a custom __eq__ becomes unhashable until it defines __hash__.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
return (self.x, self.y) == (other.x, other.y)
try:
{Point(0, 0)}
except TypeError as e:
print("TypeError:", e)
class FrozenPoint(Point):
def __hash__(self):
return hash((self.x, self.y))
print(len({FrozenPoint(0, 0), FrozenPoint(0, 0)}))Example explained
Line 1Point defines __eq__ only, so Python sets Point.__hash__ to None and the set literal raises TypeError.
Line 2FrozenPoint.__hash__ delegates to a tuple of the same fields used by __eq__, keeping the two consistent.
Line 3The final set has length 1 because the two instances are equal and hash equally, so the second is discarded.
Line 4Hashing a tuple of attributes only stays valid if you never reassign x or y after the object is stored.
Which operations a frozenset supports
Shows that read-only set algebra works on frozensets and that the result type follows the left operand.
fs = frozenset({1, 2, 3})
s = {3, 4}
print(type(fs | s).__name__, sorted(fs | s))
print(type(s | fs).__name__, sorted(s | fs))
print(fs.issubset({1, 2, 3, 4}), sorted(fs & s))
try:
fs.add(9)
except AttributeError as e:
print("AttributeError:", e)Example explained
Line 1fs | s returns a frozenset: the left operand's type decides the result type.
Line 2s | fs returns a plain set for the same reason, so the type of a union depends on operand order.
Line 3issubset and & work because they only read the operands and build a new object.
Line 4add does not exist at all on frozenset, so the failure is an AttributeError rather than a TypeError.
Important notes
frozenset({1, 2}) == {1, 2} is True, but d[{1, 2}] still raises TypeError, because the lookup hashes the argument before any comparison happens; convert on lookup as well as on insert.
hash() of a frozenset containing strings varies between interpreter runs due to hash randomization, so never persist hash values or rely on iteration order; equality is stable, hash numbers are not.
Common mistakes
Calling frozenset("cab") expecting a one-element container: strings are iterable, so you get frozenset({'a', 'b', 'c'}), which compares equal to frozenset("abc") and silently merges unrelated keys.
Writing fs |= other and thinking the frozenset was updated in place: |= rebinds the name to a brand new frozenset, so any dict or set already holding the old object keeps the old value.
Storing a set in a dict as a key, for example counts[{"a"}] = 1, which fails immediately with TypeError: unhashable type: 'set' rather than converting automatically.
Try it yourself
Change, predict, then run
Given pairs = [["a", "b"], ["b", "a"], ["a", "c"]], build a set of frozensets so that unordered duplicates collapse, then print its length and each pair sorted.
Open the Python workspaceCheck your understanding
A dict d has frozenset({1, 2}) as a key. Why does d[{1, 2}] raise TypeError instead of returning the value, given that frozenset({1, 2}) == {1, 2} is True?
- Lookup hashes the argument before comparing anything, and set provides no __hash__ at all
- A frozenset never compares equal to a set, so the key can never be located
- dict keys are restricted to a fixed allowlist of types that does not include frozenset
- The set's hash is recomputed on every access, so it lands in a different slot each time
Show answer
A dict lookup calls hash() on the argument first to pick a slot, and set has __hash__ set to None, so the failure happens before equality is ever consulted. The last option is tempting because it invokes changing hashes, but a set has no hash to recompute; Python removes hashing entirely rather than letting an unstable value be used.