A dict finds a key in roughly constant time by computing hash(key), jumping to a slot based on that number, and then confirming the match with ==. That two-step lookup is the whole story behind hashability. An object can be a dict key or set member only if its hash never changes while it’s stored and if equal objects always produce equal hashes. Interview questions on this topic test whether you can explain why lists are rejected, why a custom class suddenly becomes unhashable, and what goes wrong when someone forces the issue.
The two rules a key must follow
The data model spells out the contract:
- Objects that compare equal must have the same hash. If
a == b, thenhash(a) == hash(b). Otherwise the dict would look in the wrong slot for an equal key. - The hash must not change during the object’s lifetime (or at least while it’s in a dict or set). The dict stores the entry in a slot chosen from the hash at insertion time and never recomputes it.
The reverse of rule 1 doesn’t hold. Different objects can share a hash; that’s a collision, and the == check resolves it. In CPython, hash(-1) and hash(-2) are both -2, because -1 is reserved as an error signal in the C API. Collisions cost a little speed, never correctness.
What’s hashable out of the box
Immutable built-ins are hashable: int, float, str, bytes, tuple, frozenset, None. Mutable containers aren’t:
seen = {}
seen[["ana", "2026-10-07"]] = 1TypeError: cannot use 'list' as a dict key (unhashable type: 'list')Python 3.14’s message is more specific than older versions’ unhashable type: 'list'. The standard fix is to convert to the immutable equivalent: a tuple instead of a list, a frozenset instead of a set.
seen[("ana", "2026-10-07")] = 1
print({frozenset({"read", "write"}): "editor"}[frozenset({"write", "read"})])editorA frozenset key matches regardless of insertion order, which makes it handy for “set of permissions” lookups.
A tuple is only as hashable as its contents. Python hashes a tuple by combining the hashes of its items, so one list inside spoils it:
hash(("ana", ["admin"]))TypeError: unhashable type: 'list'Candidates often say “tuples are hashable” without the qualifier. The accurate version is “tuples of hashable items are hashable.”
Equal numbers are the same key
Rule 1 has a consequence that surprises people:
d = {1: "int"}
d[1.0] = "float"
d[True] = "bool"
print(d){1: 'bool'}1 == 1.0 == True, so their hashes must match, and the dict treats them as one key. It keeps the first key object it saw (1) and updates the value each time. This rarely matters until you build a dict keyed by mixed JSON values and lose an entry.
Custom classes: eq removes hash
A plain class is hashable by identity: hash(obj) is derived from id(obj), and == is identity too, so the contract holds. The moment you define __eq__ to compare by value, Python sets __hash__ to None:
class Email:
def __init__(self, addr):
self.addr = addr
def __eq__(self, other):
return isinstance(other, Email) and self.addr.lower() == other.addr.lower()
{Email("Ana@x.com")}TypeError: cannot use 'Email' as a set element (unhashable type: 'Email')This is deliberate. Keeping identity-based hashing would break rule 1: Email("Ana@x.com") == Email("ana@X.com") is true, but their identity hashes differ, so a set would happily hold both. Python makes you decide.
If the object should be a key, define __hash__ from the same fields __eq__ uses, normalised the same way:
class Email:
__slots__ = ("addr",)
def __init__(self, addr):
self.addr = addr
def __eq__(self, other):
return isinstance(other, Email) and self.addr.lower() == other.addr.lower()
def __hash__(self):
return hash(self.addr.lower())
print(len({Email("Ana@x.com"), Email("ana@X.com")}))1Hashing a tuple of the fields (hash((self.a, self.b))) is the usual idiom for multiple fields. A mismatch between the two methods (hashing self.addr raw while comparing lower-cased) silently breaks rule 1 and produces duplicate “equal” entries.
The orphaned key
Defining __hash__ on a mutable object is legal, which is how this bug happens:
class Bad:
def __init__(self, addr): self.addr = addr
def __eq__(self, o): return self.addr == o.addr
def __hash__(self): return hash(self.addr)
k = Bad("ana@x.com")
cache = {k: "profile"}
k.addr = "ben@x.com"
print(k in cache, Bad("ana@x.com") in cache, len(cache), list(cache.values()))False False 1 ['profile']The dict has one entry, and neither lookup finds it. Looking up k computes the hash of "ben@x.com" and checks a slot where nothing was stored. Looking up a fresh Bad("ana@x.com") lands in the right slot, finds the stored object, and compares; but that object’s addr is now "ben@x.com", so == fails. The entry is still there, taking memory, reachable only by iterating. No error is raised at any point. In a cache this shows up as a hit rate that slowly falls, which is a nasty thing to debug.
The fix is to make whatever feeds __hash__ immutable: a frozen dataclass, a NamedTuple, or a class with read-only properties. The dataclass guide shows the frozen=True approach.
NaN, the odd one out
float("nan") is hashable but not equal to itself, which bends rule 1:
nan = float("nan")
d = {nan: 1}
print(nan in d, float("nan") in d, nan == nan)True False FalseThe same object is found because dict lookup checks identity before calling ==. A different NaN object isn’t. Data pipelines that use NaN as a “missing” key run into this; use None or a sentinel instead.
Hash randomisation and why order isn’t a feature
str and bytes hashes are salted per process (sys.flags.hash_randomization is 1 by default), so hash("ana") differs between runs. Two consecutive runs here gave 1681583975170171171 and -8542215852218441883. That defends against attackers who send many keys with colliding hashes to slow a server down. It also means you must never persist hash() values or use them to shard data across machines; use hashlib for anything stored or shared. Dicts keep insertion order (guaranteed since 3.7), but sets don’t promise any order, and set iteration order for strings can change between runs because of this salting.
Interview exercise: deduplicate orders
You receive order records as dicts like {"sku": "A1", "qty": 2, "tags": ["gift"]} and need to drop exact duplicates while keeping the first occurrence. set(orders) raises TypeError. How do you do it?
Answer
Dicts are unhashable, and so is the list inside, so you need a hashable key that represents each record’s content. Build one by converting every mutable part to its immutable counterpart: key = (o["sku"], o["qty"], tuple(o["tags"])), or generically frozenset over the items with nested lists turned into tuples. Then keep a seen set and append an order to the output only if its key isn’t in seen. That’s O(n) and preserves the original order. Whether tags should be a tuple or frozenset is a real question: if ["gift", "rush"] and ["rush", "gift"] mean the same order, use frozenset. A shortcut like json.dumps(o, sort_keys=True) as the key also works and handles nesting, at the cost of serialisation time and treating 1 and 1.0 as different, which may or may not be what you want.