Embercode
✕
Built-in CollectionsLesson 8 of 17

45 min read

Custom keys in Python

Why this matters

A dict or set decides whether two keys are "the same" by calling two methods on the key: __hash__ and __eq__. For str, int and tuple, Python has written these methods for you, so a dict with string keys behaves as you expect. For a class you write yourself, such as Point or Customer, Python does not know what "the same" means. If you do nothing, the answer is "only the same object", which is rarely what you want.

This lesson shows how to tell Python what "the same" means for your own class. You will write __eq__, write __hash__, and see what breaks when either one is wrong. The previous lesson, on hash collisions, explained how the table uses the hash number to pick a slot. This lesson explains how to give the table the right hash number and the right equality rule.

Words you need first

A reference is a name that points to an object in memory. A variable that holds a Point holds a reference to the Point. Two names can point to two different objects that have the same field values.

Identity means "is this the same object?" The operator is checks identity. Two names have the same identity when they point to the same object.

Equality means "do these objects hold the same value?" The operator == checks equality, and it calls the method __eq__. You decide what equality means for your class. For a Point, two points are equal when their x and y values match.

The method __eq__(self, other) is where you write the equality rule. Python's default version returns true only when the two names point to the same object. It is inherited from the base class object.

The method __hash__(self) returns an int. It is the number the dict uses to choose a slot, as the previous lesson showed.

An example: two identical points, two different keys

Here is a small class with no methods added. It has two fields, x and y, and nothing else.

Python · editable

The output is:

contains a new (1, 2): False

Both objects have x equal to 1 and y equal to 2. The set still says the second one is not there. Each object inherited its hash from object, which is based on identity, so two new objects almost always get different hashes and land in different slots. The set looks in the wrong slot and never calls __eq__.

This is the default behaviour, and it is correct for many classes. A thread or a file handle should be equal only to itself. A Point should be equal to any other point with the same coordinates. To get that, you define both methods.

The contract between eq and hash

Python gives two promises that your code must keep.

The first promise is about __eq__. It must be an equivalence rule. That means it is reflexive (every object equals itself), symmetric (if a == b then b == a), transitive (if a == b and b == c then a == c), and consistent (the answer does not change while the fields do not change). It should also return False when the other object is of a different type, such as None or a string.

The second promise connects __eq__ and __hash__. If a == b is true, then hash(a) and hash(b) must be the same number. The reverse does not have to hold. Two different objects may share a hash, as the previous lesson showed for "Aa" and "BB".

Why does the second promise matter? A dict looks only in the slots that the hash leads to, and calls __eq__ only on the entries it finds there. If two equal objects had different hashes, they could land in different slots, and the dict would never compare them. The lookup would miss a key that is really there.

The rule is simple to state: whenever you define __eq__, you must define __hash__ in a way that agrees with it. Use the same fields in both methods.

Worked example 1: eq alone is not enough

Here is a first attempt that defines __eq__ but forgets __hash__.

Python · editable

The output is:

caught: cannot use 'EqualsOnly' as a set element (unhashable type: 'EqualsOnly')

Python does not allow the mistake to go unnoticed. When a class defines __eq__ and not __hash__, Python sets __hash__ to None, so the class cannot be put in a set or used as a dict key. The error names the class and tells you the reason. This is one place where Python is stricter than Java, which lets the same mistake run silently and simply miss the key. Read the message as a clue: the class is unhashable, so it needs a __hash__.

Worked example 2: a complete Point key

The fix is to define both methods, using the same fields in each.

Python · editable

The output is:

size: 1 contains new (1, 2): True
contains (2, 1): False
eq None: False

The __eq__ method follows a standard pattern, with four steps:

  1. If other is the same object, return True. This is a fast path.
  2. If other is not a Point, return False. The isinstance check tests the type, and it also handles None, because None is not a Point.
  3. Compare the fields. The tuple (self.x, self.y) puts the two fields side by side, and the tuples are compared item by item.
  4. Return the result.

The __hash__ method hashes the same tuple of fields. Since __eq__ compares x and y, the hash uses x and y, and equal points get equal hashes. The __repr__ method only changes how the point prints, so the output is readable.

Two points with the same coordinates count as one entry in the set. The set has size 1, and a new Point with the same coordinates is found. The point (2, 1) is not found, because its fields are in the other order, and __eq__ compares x with x and y with y. This is the behaviour a key class needs.

Worked example 3: a dataclass writes both methods

Python's standard library can write __init__, __eq__, __hash__ and __repr__ for you. A dataclass with frozen=True also makes the fields read-only. A read-only field cannot change after the object is made, so the hash cannot change either. That removes the mutable key trap, which the next section describes.

Python · editable

The output is:

people size: 2
Person(name='Ana', age=30)
caught: FrozenInstanceError cannot assign to field 'age'

Two Person objects with the same name and age count as one entry. The two None names are also equal to each other, so the four objects make two entries: one for Ana and one for the missing name. The dataclass compares the fields in order and handles None without any extra code.

The frozen=True option has a cost. A frozen object cannot be changed, so a change means building a new object. Use it for keys and for simple values such as a point or a money amount. Write the methods yourself when a class has fields that change or when equality should ignore some fields.

The plain @dataclass without frozen=True also writes __eq__, but it sets __hash__ to None. A plain dataclass therefore cannot be a dict key, and the error is the same as in worked example 1.

The mutable key trap

A key whose hash depends on a field that can change causes the same trap as in the dicts lesson. The entry stays in its old slot, and lookups look in the new one. The dicts lesson shows the full output with a Point class that changes after it is stored. The rule is the same here: never change a field that takes part in __eq__ or __hash__ while the object is a key. Make the fields read-only, as a frozen dataclass does, or store a copy.

Worked example 4: the method name

Python checks the names of special methods only by convention, so a typo in the name is not an error. A method named equals instead of __eq__ does nothing special. The == operator still calls the default __eq__, which compares identity, so the two objects are not equal.

Python · editable

The output is:

equals method: True
operator ==: False

The method works when you call it by name, but the operator == and the dict ignore it. The fix is to rename it __eq__. Python has no built-in check that catches the typo, so check the spelling of the double underscores each time you write one of these methods.

Worked example 5: a field that is not hashable

A key class that stores a list in a field fails when the hash is computed, because a list cannot be hashed. Here is the run:

Python · editable

The output is:

caught: unhashable type: 'list'

The message names the type that cannot be hashed, list. The fix is to hash a tuple made from the list, as hash(tuple(self.tags)). A tuple can be hashed only when all of its items can, so this works for a list of strings but not for a list of lists. This works only when the list does not change after the key is stored, so make sure the object is not changed afterwards.

A field that holds None is different. hash(None) works, so a key with a None field hashes like any other key. The dataclass example above shows this.

Worked example 6: the wrong type in eq

When __eq__ is written without a type check, a comparison with a different type can crash. The next program compares a Name with a string:

Python · editable

The output is:

caught: 'str' object has no attribute 'first'

The message says that a string has no attribute first, and the traceback points at the line in __eq__. The fix is the isinstance check from worked example 2, which returns False for other types instead of crashing. A dict makes this call only when two keys have the same hash, so the crash may appear only on some inputs. Always check the type first.

Common mistakes

Defining __eq__ without __hash__. The class becomes unhashable, and the error names the class, as worked example 1 shows. Define __hash__ next to __eq__, or use a frozen dataclass.

Naming the method equals. Python only calls __eq__ for == and for dict lookups. Use the double underscores, as worked example 4 shows.

Changing a key after it goes into a dict or set. The entry becomes unreachable. Use read-only fields for keys.

Skipping the type check in __eq__. Comparing with None or a string then raises an error or returns the wrong answer. Use isinstance.

Hashing a list field. The hash fails with unhashable type: 'list'. Convert the list to a tuple first.

Comparing object fields with is. Use is only for None and for identity checks. Compare values with ==, so that two equal strings compare as equal.

Practice

Exercise 1. Write __eq__ and __hash__ for a class Person with a field name and a field age. The name may be None. Then explain why a set holding Person("Ana", 30) also contains a second Person("Ana", 30).

Show the answer:

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age
 
    def __eq__(self, other):
        if self is other:
            return True
        if not isinstance(other, Person):
            return False
        return (self.name, self.age) == (other.name, other.age)
 
    def __hash__(self):
        return hash((self.name, self.age))

Both objects have the same name and the same age, so __eq__ returns True. They also have the same hash, because __hash__ uses the same two fields. The set looks in one slot, finds the first object, and calls __eq__, which returns True. The set therefore keeps one entry. The tuple comparison handles a None name without any extra code. A frozen dataclass gives the same result in fewer lines.

Exercise 2. A set holds Point(1, 2), where Point has __eq__ and __hash__ as in worked example 2. What does Point(2, 1) in points return, and why?

Show the answer: it returns False. The point (2, 1) has the same two numbers as (1, 2), but in the other order. The tuples (2, 1) and (1, 2) have different hashes, so they usually land in different slots. Even if they landed in the same slot, __eq__ would compare x with x and y with y, and the answer would still be False.

Exercise 3. Change a Point class so that the mutable key trap cannot happen. Explain what Python does when code tries to change a field of a frozen dataclass.

Show the answer: make the class a frozen dataclass.

from dataclasses import dataclass
 
 
@dataclass(frozen=True)
class Point:
    x: int
    y: int

If code then writes key.x = 9, Python raises FrozenInstanceError. The object's hash can no longer change, so it stays in the slot where it was put. If you need a point at a different position, create a new object.

Exercise 4. Why does Point(1, 2) == None return False in worked example 2, even though the method never compares fields with None?

Show the answer: the check isinstance(other, Point) is False for None, because None is not a Point. The method returns False before it reads any field of other. This is why the isinstance check is the standard way to write __eq__.

Exercise 5. A class Tag has __hash__ returning hash(self.label), and label may be None. Does hash(Tag(None)) raise an error? What changes if label is a list?

Show the answer: no error. hash(None) works, so a Tag with a None label hashes like any other object. If label is a list, hash raises TypeError: unhashable type: 'list'. Store a tuple in that field, or hash tuple(label).

Summary

  • A dict or set uses __hash__ to choose a slot and __eq__ to decide whether two keys match. Your class needs both if you want value equality.
  • If two objects are equal under __eq__, they must have the same hash. Define both, using the same fields.
  • A class that defines __eq__ without __hash__ cannot be a key, and Python says so with an error.
  • Use an isinstance check in __eq__, so that a comparison with another type returns False instead of crashing.
  • Do not change a field that takes part in equality while the object is a key. Use a frozen dataclass when you can.
  • Check the spelling of __eq__ and __hash__, because a misspelled name is silently ignored.
Check yourself: Hashing with dict and set (8 questions) →Practice this topic: coding problems for Built-in Collections →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Hash collisions in Python