Create your own
Lesson illustration

Determining Object Hashability for Dictionaries and Sets

Good to see you again. In the previous lesson, you chose between shallow, deep, and targeted copying by tracing which mutable objects were shared. That same attention to object state matters here: dictionary keys and set members must remain reliably findable after insertion.

In this lesson, you will determine whether an object is hashable, distinguish hashability from mere immutability, and decide whether a hashable object is actually a good semantic key for a dictionary or set. This is particularly useful when modeling configuration identities, caching results, deduplicating records, or building lookup tables.


The contract behind dictionary keys and set members

A dictionary is not a list of key-value pairs that Python searches one by one. A set is not a list whose duplicates are removed by repeated scanning. Both are hash-based collections: Python uses an object’s hash value to locate a small candidate area quickly, then uses equality to determine whether it has found the intended key.

Conceptually, a lookup such as this:

tunnels[("chennai", 101)]

has two stages:

  1. Python computes a hash for ("chennai", 101) to locate where an equivalent key should be stored.
  2. If necessary, Python compares candidate keys using == to confirm a match.

A hash is therefore a routing value, not a unique identifier. Two unequal objects can, in principle, have the same hash; Python handles those collisions with equality comparisons. The essential rule goes in the other direction:

If two objects compare equal, they must have the same hash value.

Formally, for hashable objects and :

There is one more requirement: the hash value must remain stable for the object’s lifetime. Otherwise, Python would store the key using one lookup location and later search for it using another.

A deliberately impossible example makes the problem concrete. Imagine that Python allowed a list to be a key:

key = ["chennai", 101]
inventory = {key: "active"}

If this were permitted, mutating the list later could change the value on which its hash depended:

key.append("backup")

The dictionary entry would still physically exist, but future lookups would search based on the changed key state. That is why ordinary mutable containers such as list, dict, and set are unhashable.

Immutable vs. hashable: What's the difference?

Watch “Immutable vs. hashable: What's the difference?” from Python and Pandas with Reuven Lerner. It provides a useful visual model of why dictionaries need a stable lookup value, then handles two important exceptions to the usual “immutable means hashable” shortcut.

Begin at dictionary mechanics, concentrating on the distinction between “immutable” and “hashable” and on why a changed key would become unrecoverable. Then watch tuple contents for the nested-mutable-element exception. Finish with custom objects, where the video contrasts identity-based hashing with a dangerous hash based on mutable instance state.

The practical built-in type rules

For everyday Python, use this table as an initial classification:

Object type or exampleHashable?Suitable as a key or set member?
str, int, float, bool, bytesYesUsually yes, if their value is the identity you mean to model
tupleDependsYes only when every element is hashable
frozensetDependsYes only when every member is hashable
list, dict, set, bytearrayNoNo
Default instance of a user-defined classUsually yesOnly when instance identity is the intended meaning
Class instance with value-based __eq__ but no compatible __hash__NoNo

The recursive rule for immutable containers is important:

valid_key = ("chennai", 101, "ipsec")
invalid_key = ("chennai", ["wan0", "wan1"])

The first tuple is hashable because each component is hashable. The second tuple is not hashable because its list component is mutable and unhashable.

hash(valid_key)

hash(invalid_key)
# TypeError: unhashable type: 'list'

Likewise, frozenset is useful when a collection should be both immutable and unordered:

capabilities = frozenset({"ipsec", "bfd", "qos"})
profiles = {capabilities: "secure-edge"}

This is valid because every string in the frozen set is hashable.


Test hashability directly, but judge suitability separately

The most direct runtime test is simply to call hash():

def is_hashable(value: object) -> bool:
    try:
        hash(value)
    except TypeError:
        return False
    else:
        return True

For example:

samples = [
    "branch-chennai",
    443,
    ("chennai", "wan0"),
    ("chennai", ["wan0"]),
    ["chennai", "wan0"],
    {"site": "chennai"},
    frozenset({"ipsec", "bfd"}),
]

for sample in samples:
    print(repr(sample), is_hashable(sample))

The important results are:

'branch-chennai' True
443 True
('chennai', 'wan0') True
('chennai', ['wan0']) False
['chennai', 'wan0'] False
{'site': 'chennai'} False
frozenset({'ipsec', 'bfd'}) True

collections.abc.Hashable can also be useful as a structural check:

from collections.abc import Hashable

isinstance(("chennai", 101), Hashable)
# True

isinstance(["chennai", 101], Hashable)
# False

For code that must actually use the object as a key, hash(value) remains the most concrete check: it confirms that Python can obtain a hash value now.

However, “hashable” does not automatically mean “appropriate key.” Before using an object in a dictionary or set, apply this four-part design check:

  1. Can Python hash it?
    hash(value) must succeed.

  2. Does equality mean what the application needs?
    A key represents an equivalence relation. Decide what makes two values “the same.”

  3. Will every equality-relevant attribute remain stable while stored?
    Mutating a field involved in equality or hashing is unsafe.

  4. Do equal instances have equal hashes?
    This is mandatory for custom value objects.

For configuration-like data, create a key whose structure explicitly matches the equality semantics you need. Suppose a policy’s prefix order matters but enabled features do not:

policy_key = (
    "chennai",
    ("10.20.0.0/16", "10.30.0.0/16"),
    frozenset({"ipsec", "bfd"}),
)

The outer tuple says all three fields contribute to identity. The nested tuple preserves prefix order. The frozenset declares that feature ordering is irrelevant.

This is generally clearer and safer than attempting to use a mutable policy dictionary as a key. It also makes the key’s semantics reviewable in code.

One small but useful edge case: numeric values follow Python’s normal equality rules.

keys = {1: "integer", 1.0: "float", True: "boolean"}
print(keys)
# {1: 'boolean'}

Because 1 == 1.0 == True, these values have compatible hashes and occupy one dictionary entry. This is correct Python behavior, but it means identifiers from different domains should not be represented carelessly with bare numeric keys.

3. Data model — Python 3.14.2 documentation

Read the relevant parts of the Python Language Reference’s “Data model.” This is the authoritative source for the constant-hash requirement, the relationship between equality and hashing, and Python’s behavior when a class customizes equality.

In Section 3.2.7.1, “Dictionaries,” read the dictionary-key rule. Focus on why the implementation requires the key hash to remain constant, rather than memorizing a list of prohibited types. Then locate the object.__hash__() definition in the special-method discussion. Read the hash contract, especially the recommendation to hash a tuple of the fields that participate in equality. Continue to custom-class behavior, noting what happens when a class defines __eq__() but omits __hash__().


Custom classes: identity keys versus value keys

A default user-defined instance is a subtle but important exception to the “mutable objects are unhashable” rule.

class Connection:
    pass


first = Connection()
second = Connection()

print(first == second)
# False

runtime_state = {first: "connected"}
first.peer = "198.51.100.10"

print(runtime_state[first])
# connected

Connection instances use the default identity-based equality inherited from object. Two separately created instances are unequal even if they later hold identical attributes. Their default hashes are likewise identity-based, so changing first.peer does not change the dictionary’s lookup meaning.

This is appropriate when the key means:

“This exact live object instance.”

For example, an in-memory registry tracking state per connection object can legitimately use instance identity as the key. It is usually not appropriate when the key should mean:

“Any tunnel with this site and tunnel ID.”

That is value semantics. Once equality is based on selected fields, hashing must follow those same fields.

Defining equality makes a class unhashable by default

Consider a class representing a route identity:

class RouteKey:
    def __init__(self, site: str, vrf: str) -> None:
        self.site = site
        self.vrf = vrf

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, RouteKey):
            return NotImplemented
        return (self.site, self.vrf) == (other.site, other.vrf)

Now two logically equivalent instances compare equal:

left = RouteKey("chennai", "prod")
right = RouteKey("chennai", "prod")

assert left == right

But they cannot be used in a set or as dictionary keys:

hash(left)
# TypeError: unhashable type: 'RouteKey'

This is a protective default. Python recognizes that you have changed equality semantics but have not stated a compatible hashing rule.

If RouteKey is intended to be an immutable value object, its hash can be based on exactly the same fields:

class RouteKey:
    def __init__(self, site: str, vrf: str) -> None:
        self.site = site
        self.vrf = vrf

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, RouteKey):
            return NotImplemented
        return (self.site, self.vrf) == (other.site, other.vrf)

    def __hash__(self) -> int:
        return hash((self.site, self.vrf))

The tuple is not incidental. It precisely expresses the rule:

assert left == right
assert hash(left) == hash(right)

But this implementation is correct only under an immutability contract: site and vrf must not change after the object becomes a key or set member.

The “lost key” bug

Here is the bug to avoid:

routes = {
    RouteKey("chennai", "prod"): "preferred"
}

key = next(iter(routes))
key.vrf = "lab"

print(routes[key])
# KeyError is possible

The object is still physically inside routes, but its new hash may direct lookup to a different internal location. The dictionary has not been corrupted; the key contract has been violated.

For a mutable class whose equality is based on mutable fields, explicitly keep it unhashable:

class MutableRouteConfig:
    def __init__(self, site: str, prefixes: list[str]) -> None:
        self.site = site
        self.prefixes = prefixes

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, MutableRouteConfig):
            return NotImplemented
        return (
            self.site,
            self.prefixes,
        ) == (
            other.site,
            other.prefixes,
        )

    __hash__ = None

This prevents accidental insertion into a set or dictionary as a key. Later in the course, immutable value objects with dataclasses will provide a more concise way to express this kind of design.


A concise key-selection workflow

When designing a dictionary key or a set member, use this workflow:

  1. State the identity rule in domain terms.
    For example: “A tunnel is identified by site and tunnel ID,” or “This registry tracks each live session object.”

  2. Choose an immutable representation where possible.
    Strings, numbers, tuples, and frozen sets cover many ordinary cases.

  3. For a custom value class, align __eq__ and __hash__.
    Hash the tuple of every field used in equality.

  4. Prevent mutation of equality-relevant state.
    If the state must remain mutable, do not make the object hashable by value.

  5. Do not persist Python hash values.
    A hash() result is for in-process collection lookup, not a stable external ID. In particular, string and bytes hashes are deliberately salted and may differ across Python processes.

For fast lookup tables, key design has performance and correctness consequences. A tuple like ("chennai", 101) is usually a strong key because it is explicit, immutable, and naturally comparable. A mutable dictionary containing that same information is better treated as data from which a stable key is derived.


Key takeaways

An object is hashable when Python can compute a hash that stays stable and is compatible with equality: equal objects must have equal hashes. Hashability is required for dictionary keys and set members because those collections use hashes to locate entries efficiently.

Most immutable built-ins are hashable, but compound immutable containers such as tuples and frozen sets are hashable only if all their contents are hashable. Mutable containers are unhashable because their state could change their lookup behavior.

For custom classes, distinguish identity semantics from value semantics. Default instances are hashable by identity. Once you implement value-based __eq__, either provide a compatible, stable __hash__ for an immutable value object or explicitly leave the class unhashable.

Next, the course turns to function-call semantics: designing signatures with positional-only parameters, keyword-only parameters, and variadic arguments.

Can't find a good explanation? Sign up and we'll make it for you

Sign up