Python Equality Operator: How == Really Works
python equality operator: Learn how the Python equality operator works under the hood: == vs is, __eq__ dispatch, custom equality, and hashability.
The python equality operator, ==, is one of the most frequently used constructs in the language, yet its behavior is easy to misunderstand. Consider the difference between these two comparisons:
a = [1, 2, 3] b = [1, 2, 3] print(a == b) # True print(a is b) # False
Both lists contain the same values, so == returns True. But a and b are two distinct objects in memory, so is returns False. Understanding precisely when == compares values versus identities is the foundation for working with the equality operator correctly.
The Core Distinction: == vs is
The == operator is defined to compare the values of two objects. The is operator compares object identity — whether two names refer to the exact same object in memory. For immutable built-in types such as integers and strings, the two can appear to behave identically because Python interns and caches certain values:
x = 256 y = 256 print(x is y) # True, because small integers are cached x = 1000 y = 1000 print(x is y) # False, because these are separate objects
This caching is an implementation detail of CPython, not a language guarantee. Code that relies on is for value comparison of integers or strings is fragile and should be avoided. Use is only when you genuinely need to check object identity, such as comparing against None or a singleton.
How == Invokes eq Behind the Scenes
When you write a == b, Python does not perform a direct memory comparison. Instead, it dispatches to the __eq__ method of the left operand's type:
class Temperature: def __init__(self, celsius): self.celsius = celsius def __eq__(self, other): if isinstance(other, Temperature): return self.celsius == other.celsius return NotImplemented t1 = Temperature(20) t2 = Temperature(20) print(t1 == t2) # True
The dispatch follows a specific protocol. Python first calls type(a).__eq__(a, b). If that method returns NotImplemented, Python then calls type(b).__eq__(b, a). If both return NotImplemented, Python falls back to identity comparison, which is equivalent to a is b.
Returning NotImplemented is not the same as returning False. It signals to the interpreter that the current type does not know how to compare itself with the given operand, allowing the other side a chance to respond. This is essential for symmetric equality across different types.
Default eq Behavior: Identity Comparison
If a class does not define __eq__, it inherits the default implementation from object. That default compares object identity:
class Widget: pass w1 = Widget() w2 = Widget() print(w1 == w2) # False, because they are different objects
This default behavior means that two instances of a custom class are never equal unless they are the same object. For many domain objects, this is not what you want. Two Point instances with the same coordinates, two User instances with the same ID, or two Order instances with the same order number should compare equal even if they are distinct objects in memory.
Implementing Custom eq
To give a class value-based equality, override __eq__. The method receives the other operand and must return a boolean, NotImplemented, or (in rare cases) another truthy or falsy value:
class Point: def __init__(self, x, y): self.x = x self.y = y def __eq__(self, other): if not isinstance(other, Point): return NotImplemented return self.x == other.x and self.y == other.y def __repr__(self): return f"Point({self.x}, {self.y})" p1 = Point(3, 4) p2 = Point(3, 4) p3 = Point(5, 6) print(p1 == p2) # True print(p1 == p3) # False print(p1 == "not a point") # False
The isinstance check combined with NotImplemented ensures that comparing a Point with an unrelated type does not raise an exception. Python then falls back to the other operand's __eq__, and ultimately to identity comparison, which yields False for unrelated objects.
A common mistake is to return False directly when the types do not match:
def __eq__(self, other): if not isinstance(other, Point): return False return self.x == other.x and self.y == other.y
This breaks symmetry. If Point.__eq__ returns False for a str operand, but the str type has its own __eq__ that could handle a Point, Python never gets the chance to try it. Returning NotImplemented is the correct protocol.
The ne Operator and !=
In Python 3, != is derived from == by default. If you do not define __ne__, the interpreter inverts the result of __eq__. This means you rarely need to implement __ne__ explicitly:
class Point: # __eq__ defined as above pass p1 = Point(1, 2) p2 = Point(1, 2) p3 = Point(3, 4) print(p1 != p2) # False print(p1 != p3) # True
The automatic inversion works correctly for most cases. However, if __eq__ returns NotImplemented for a particular operand, != also returns NotImplemented (converted to a boolean False in a boolean context), and Python applies the same fallback protocol. Explicitly defining __ne__ is only necessary if you need behavior that is not the exact negation of __eq__, which is rare.
Equality and Hashability: The hash Contract
Defining __eq__ on a class has a side effect: Python sets __hash__ to None for that class, making its instances unhashable. This is a deliberate safety mechanism. The language contract states that two objects that compare equal must have the same hash value. If you define __eq__ without __hash__, Python cannot guarantee that contract, so it disables hashing entirely:
class Point: def __init__(self, x, y): self.x = x self.y = y def __eq__(self, other): if not isinstance(other, Point): return NotImplemented return self.x == other.x and self.y == other.y # This raises TypeError: unhashable type: 'Point' # point_set = {Point(1, 2)}
To make instances usable as dictionary keys or set members, define __hash__ alongside __eq__. The hash must be consistent with equality: equal objects must produce equal hashes. A common pattern is to hash a tuple of the fields used in __eq__:
class Point: def __init__(self, x, y): self.x = x self.y = y def __eq__(self, other): if not isinstance(other, Point): return NotImplemented return self.x == other.x and self.y == other.y def __hash__(self): return hash((self.x, self.y)) p = Point(3, 4) point_set = {p} print(p in point_set) # True
The tuple (self.x, self.y) hashes consistently with the equality check, so the contract is satisfied. If you later change the fields used in __eq__, update __hash__ to match.
Equality in Collections: Lists, Dictionaries, and Sets
The equality operator behaves differently depending on the collection type. Lists compare element by element, in order:
print([1, 2, 3] == [1, 2, 3]) # True print([1, 2, 3] == [3, 2, 1]) # False
Dictionaries compare key-value pairs. Two dictionaries are equal if they have the same keys and each key maps to an equal value, regardless of insertion order:
print({"a": 1, "b": 2} == {"b": 2, "a": 1}) # True
Sets compare by membership. Two sets are equal if they contain the same elements, regardless of order:
print({1, 2, 3} == {3, 2, 1}) # True
For sets and dictionary keys, the hash-based lookup relies on __hash__ and __eq__ working together. When you look up a key, Python first computes the hash to find the bucket, then uses __eq__ to confirm the match. If __hash__ and __eq__ are inconsistent, lookups can silently fail or return wrong results.
Performance and Runtime Cost of Equality Checks
The cost of == depends entirely on the types involved. For integers and small strings, comparison is fast because CPython stores the value directly or interns common values. For lists and tuples, == performs an element-by-element comparison, which is O(n) in the length of the sequence. The comparison short-circuits: as soon as one pair of elements differs, the result is False and the remaining elements are not examined.
For dictionaries, equality requires comparing both key sets and values. This is also O(n) in the number of entries. Set equality is more nuanced: Python first checks that the sets have the same length, then verifies that every element of one set is present in the other using hash-based lookup, which is O(n) on average.
Custom __eq__ methods can introduce significant cost if they perform expensive work. A naive implementation that compares large nested structures or performs I/O inside __eq__ will make every equality check slow. Keep __eq__ cheap: compare the smallest set of fields that uniquely identifies the object. If you need to compare large objects frequently, consider comparing a cached hash or a lightweight identifier first.
One additional runtime consideration: when you define __eq__ on a class, Python also disables the default __hash__. If your objects are used in sets or as dictionary keys, you must provide __hash__ explicitly. Forgetting this causes a TypeError at runtime, which is often discovered only when the collection is first populated.
Common Pitfalls with the Equality Operator
Several mistakes recur when working with == in Python. The most common is using == to check for None when is is the correct choice:
def process(value): if value is None: # correct return "empty" return f"got {value}"
Using == None works for most objects, but a custom class can override __eq__ to return True when compared with None, producing surprising results. The is operator cannot be overridden, so it is the reliable choice for singleton checks.
Comparing floating-point numbers with == is another frequent source of bugs. Binary floating-point representation means that values like 0.1 + 0.2 do not equal 0.3:
print(0.1 + 0.2 == 0.3) # False
This is not a Python bug; it is a consequence of IEEE 754 floating-point arithmetic. Use an epsilon-based comparison or math.isclose when exact equality is not meaningful.
Mutating an object that is used as a dictionary key or set member breaks the hash-based data structure. If you change the fields that contribute to the hash after insertion, the object will be in the wrong bucket and lookups will fail:
class Point: def __init__(self, x, y): self.x = x self.y = y def __eq__(self, other): if not isinstance(other, Point): return NotImplemented return self.x == other.x and self.y == other.y def __hash__(self): return hash((self.x, self.y)) p = Point(1, 2) d = {p: "value"} p.x = 99 # mutating after insertion print(d.get(Point(1, 2))) # None, because the hash changed
Treat objects that participate in hash-based collections as immutable, or use a separate immutable key type.
Finally, remember that == on two objects of different types does not raise an error by default. It returns False through the NotImplemented fallback protocol. This is usually the desired behavior, but it can mask bugs where you accidentally compare incompatible types and silently get False instead of a TypeError. If you want strict type checking in comparisons, validate the operand type explicitly inside __eq__ and raise TypeError when the types are incompatible, rather than returning NotImplemented.