Python Hashable Dictionary Keys: How to Use Them Correctly
python hashable dictionary keys: Learn what makes an object hashable in Python, how to use custom classes as dictionary keys, and avoid common pitfalls.
python hashable dictionary keys requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
In Python, a dictionary key must be hashable. This means the object must have a stable hash value and an equality method that is consistent with that hash. Understanding what makes an object hashable is essential when you want to use custom objects as keys in a dictionary. The rule is simple: if you can pass an object to hash() and it returns an integer without raising TypeError, and if that integer never changes during the object's lifetime, the object can serve as a key. But the interaction between __hash__ and __eq__ introduces subtleties that often surprise developers who are new to the language.
What Makes an Object Hashable in Python
Hashability is defined by the presence of two special methods: __hash__ and __eq__. An object is hashable if its __hash__ method returns an integer and its __eq__ method defines equality in a way that is compatible with that hash. The critical invariant is: if two objects compare equal, their hash values must be equal. If you violate this, dictionaries and sets will behave unpredictably, because they rely on the hash to locate entries and then use equality to confirm a match.
Most built-in immutable types are hashable: int, float, str, bytes, tuple, and frozenset. Mutable containers like list, dict, and set are not hashable because their contents can change, which would change their equality and potentially their hash. Python explicitly sets __hash__ to None for these types, so calling hash([1, 2]) raises TypeError: unhashable type: 'list'.
The Hash Contract: hash and eq
The relationship between __hash__ and __eq__ is a contract, not just a convention. When you define __eq__ in a class, Python automatically sets __hash__ to None unless you also define it. This prevents you from accidentally creating an object that compares equal but has no hash, which would break dictionary usage. For example:
class Point: def __init__(self, x, y): self.x = x self.y = y def __eq__(self, other): return isinstance(other, Point) and self.x == other.x and self.y == other.y p = Point(1, 2) print(hash(p)) # TypeError: unhashable type: 'Point'
To make Point hashable, you must provide __hash__. A straightforward approach is to hash a tuple of the attributes that define equality:
class Point: def __init__(self, x, y): self.x = x self.y = y def __eq__(self, other): return isinstance(other, Point) and self.x == other.x and self.y == other.y def __hash__(self): return hash((self.x, self.y))
Now Point(1, 2) can be used as a dictionary key. The hash is derived from the same values used in __eq__, preserving the invariant.
Using Built-in Types as Dictionary Keys
Built-in immutable types are the most common dictionary keys. Strings and integers are used everywhere, but tuples are particularly useful when you need a composite key. For example, a coordinate pair (x, y) can be a tuple key:
d = {} d[(3, 4)] = "origin" print(d[(3, 4)]) # "origin"
Tuples are hashable as long as all their elements are hashable. If you put a list inside a tuple, the tuple becomes unhashable because the list is mutable and unhashable. This is a common source of errors:
key = ([1, 2], 3) # TypeError: unhashable type: 'list'
Frozensets are also hashable and can be used when you need an unordered set of values as a key. They are less common but useful for representing a collection of unique items where order does not matter.
Creating Custom Classes That Work as Keys
When you design a class that should be used as a dictionary key, you need to decide which attributes define equality and ensure they are immutable. If any of those attributes can change after the object is inserted into a dictionary, the hash will change, and the dictionary will no longer find the entry. This is the most frequent bug when using custom objects as keys.
Consider a class representing a user account. If you use the user ID as the equality attribute, that ID should not change. A safe implementation might look like this:
class User: def __init__(self, user_id, name): self.user_id = user_id self.name = name def __eq__(self, other): return isinstance(other, User) and self.user_id == other.user_id def __hash__(self): return hash(self.user_id)
If name changes later, the hash remains stable because it is based only on user_id. If you instead included name in the hash and then changed it, the object would become unfindable in any dictionary where it was already used.
Python's dataclasses provide a convenient way to create immutable, hashable objects. If you set frozen=True, the dataclass generates __hash__ based on the fields, and equality is also field-based:
from dataclasses import dataclass @dataclass(frozen=True) class Point: x: int y: int
This Point is automatically hashable and immutable, making it ideal for dictionary keys without writing boilerplate.
Common Hashability Pitfalls and Errors
The most common error is attempting to use a mutable object as a key. Lists and dictionaries raise TypeError immediately. But subtler errors occur when you define __eq__ without __hash__, or when you mutate an object after using it as a key. The latter does not raise an error at the time of mutation; it silently breaks the dictionary's lookup. For example:
class MutableKey: def __init__(self, value): self.value = value def __hash__(self): return hash(self.value) def __eq__(self, other): return isinstance(other, MutableKey) and self.value == other.value k = MutableKey(10) d = {k: "data"} k.value = 20 print(d.get(k)) # None, because the hash changed
Even if you do not change the object itself, if the hash depends on mutable state, the same problem occurs. The only safe approach is to make the attributes used in __hash__ and __eq__ immutable. This often means using property without a setter, or making the class frozen.
Another pitfall is overriding __eq__ without also defining __hash__. As mentioned, Python sets __hash__ to None, which makes the class unhashable. This is intentional, but it can surprise developers coming from languages where equality and hashing are independent.
Performance Implications of Custom Hash Functions
Dictionary performance depends on the quality of the hash function. A good hash function distributes keys evenly across the hash table, minimizing collisions. Python's built-in hash() for strings and integers is highly optimized and randomized for security. When you write a custom __hash__, you should aim for a similar distribution. Hashing a tuple of attributes is usually sufficient and leverages Python's efficient tuple hashing.
Avoid creating a hash function that returns a constant, such as return 0. This is legal but causes every key to land in the same bucket, turning the dictionary into a linked list with O(n) lookup. Similarly, a hash that uses only a small part of the key's identity can cause clustering. For instance, hashing only the first character of a string would be poor.
In practice, you rarely need to micro-optimize hash functions. Python's dictionaries already handle collisions with open addressing, and the built-in hash for tuples and frozensets is well-tested. Focus on correctness first: ensure the hash is stable and consistent with equality. If you later profile and find that dictionary lookups are slow, then consider whether your hash distribution is the cause.
Hash Randomization and Security Considerations
Python enables hash randomization by default for strings and bytes. This means the hash of a string can vary between processes, which prevents denial-of-service attacks that exploit predictable hash collisions. When you use custom objects as keys, their hash is derived from the attributes' hashes. If those attributes are strings, the randomization applies to them as well. This is usually desirable, but it means you cannot rely on the exact hash value of a string across runs.
For custom classes, the hash is not randomized unless you explicitly incorporate randomization. If you use hash(self.attribute), you inherit the randomness of that attribute's hash. If you use a custom algorithm, you are responsible for its security properties. In most applications, this is not a concern, but if you are building a public-facing service that accepts arbitrary keys, you should be aware of the potential for collision attacks. Python's built-in randomization mitigates this for strings, and using those strings as part of your key's hash extends the protection.
A more subtle issue is that the hash of a tuple is not simply the sum of the hashes of its elements; Python uses a specific combination that is also randomized for strings. This means that if you rely on a tuple's hash for persistence (for example, storing the hash in a database), it may change across Python versions or process restarts. For that reason, never persist hash values. Use the object itself as the key, not its hash.
When you design a custom class for use as a dictionary key, remember that the hash value is ephemeral. It is only meaningful within a single process and a single dictionary. If you need a stable identifier across runs, use a separate field like an integer ID or a UUID, and base your hash on that field. This approach is common in ORMs and caching systems, where objects are keyed by their primary key.
Finally, consider the memory overhead of using custom objects as keys. Each key holds a reference to the object, and the dictionary also stores the hash value. If you create many keys, the object overhead may be significant. In such cases, using a tuple of primitive values is often more memory-efficient. For example, instead of a Point object, use (x, y) as the key. The choice depends on whether you need the object's methods or just a composite value.
In summary, the ability to use custom objects as dictionary keys is a powerful feature, but it requires discipline. Ensure your objects are immutable with respect to their hash-relevant attributes, implement __hash__ and __eq__ consistently, and be mindful of how hash randomization affects your application. With these rules, you can safely leverage Python's dictionaries for complex key types without sacrificing correctness or performance.