Skip to main content

What Is the Difference Between ==, is, and __eq__ in Python

Python has three interconnected equality concepts: the == operator, the is operator, and the __eq__ method. They are closely related but serve different purposes. The == operator checks whether two objects have the same value. The is operator checks whether two variables point to the same object in memory. The __eq__ method is the underlying implementation that defines what "same value" means for a given class.

Understanding how these three pieces fit together is essential for implementing correct comparison behavior in custom classes.

The == Operator: Value Equality

When you write a == b, Python internally calls a.__eq__(b). This is the operator you use daily for comparing values:

print("hello" == "hello")
print([1, 2] == [1, 2])
print(42 == 42)

Output:

True
True
True

The == operator itself is just a convenient syntax. The actual comparison logic lives in the __eq__ method of the objects being compared.

The is Operator: Identity Check

The is operator checks whether two references point to the exact same object in memory. It does not care about the values stored in those objects, and it cannot be overridden:

a = [1, 2, 3]
b = [1, 2, 3]
c = a

print(f"a == b: {a == b}") # Same values?
print(f"a is b: {a is b}") # Same object?
print(f"a is c: {a is c}") # Same object?

Output:

a == b: True
a is b: False
a is c: True

a and b contain identical values, so == returns True. But they are two separate list objects in memory, so is returns False. The variable c is assigned directly from a, so both names reference the same object and is returns True.

info

The primary use case for is in everyday Python code is checking for None: if x is None. Since None is a singleton (only one None object exists in memory), identity comparison is both correct and slightly faster than == for this specific check.

The __eq__ Method: Defining Equality Logic

The __eq__ method is the magic method that the == operator calls behind the scenes. By default, custom classes inherit __eq__ from object, which compares by identity (the same behavior as is):

class Point:
def __init__(self, x, y):
self.x = x
self.y = y

p1 = Point(1, 2)
p2 = Point(1, 2)

print(p1 == p2)
print(p1 is p2)

Output:

False
False

Both comparisons return False because the default __eq__ checks identity, and p1 and p2 are two separate objects. To make == compare the actual coordinate values, you need to override __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

p1 = Point(1, 2)
p2 = Point(1, 2)
p3 = Point(3, 4)

print(p1 == p2)
print(p1 == p3)
print(p1 is p2)

Output:

True
False
False

Now == compares coordinates while is still checks identity. The two operators serve completely independent purposes.

How Python Resolves ==

When you write a == b, Python follows a specific resolution process:

  1. Call a.__eq__(b). If it returns a value other than NotImplemented, use that result.
  2. If a.__eq__(b) returns NotImplemented, call b.__eq__(a) as a fallback.
  3. If both return NotImplemented, fall back to identity comparison (a is b).
# Simplified version of Python's internal resolution
def resolve_equality(a, b):
result = a.__eq__(b)
if result is not NotImplemented:
return result

result = b.__eq__(a)
if result is not NotImplemented:
return result

return a is b
tip

Return NotImplemented (not False) when your __eq__ method encounters an incompatible type. This tells Python to try the other object's __eq__ method instead of immediately deciding the comparison is False. This enables cross-type comparisons to work correctly.

Common Mistakes When Implementing __eq__

Forgetting the isinstance Check

Without a type check, your __eq__ method crashes when compared with an incompatible object:

class User:
def __init__(self, user_id):
self.user_id = user_id

# Wrong: crashes on incompatible types
def __eq__(self, other):
return self.user_id == other.user_id # AttributeError if other has no user_id

user = User(1)
try:
print(user == "hello")
except AttributeError as e:
print(f"Error: {e}")

Output:

Error: 'str' object has no attribute 'user_id'

The fix is to check the type first:

class User:
def __init__(self, user_id):
self.user_id = user_id

def __eq__(self, other):
if not isinstance(other, User):
return NotImplemented
return self.user_id == other.user_id

user = User(1)
print(user == "hello") # False (resolved via fallback)

Output:

False

Returning False Instead of NotImplemented

# Wrong: prevents Python from trying the reverse comparison
def __eq__(self, other):
if not isinstance(other, User):
return False # Blocks b.__eq__(a) from ever being called

# Correct: allows Python to try the other object's __eq__
def __eq__(self, other):
if not isinstance(other, User):
return NotImplemented # Python will try other.__eq__(self)

The __hash__ Requirement

If you override __eq__, Python automatically makes your class unhashable by setting __hash__ to None. This means instances cannot be used in sets or as dictionary keys unless you also define __hash__:

class User:
def __init__(self, user_id, name):
self.user_id = user_id
self.name = name

def __eq__(self, other):
if not isinstance(other, User):
return NotImplemented
return self.user_id == other.user_id

def __hash__(self):
return hash(self.user_id)

def __repr__(self):
return f"User({self.user_id}, {self.name!r})"

# Now works correctly in sets and as dict keys
users = {User(1, "Alice"), User(1, "Alice"), User(2, "Bob")}
print(f"Unique users: {len(users)}")
print(users)

Output:

Unique users: 2
{User(1, 'Alice'), User(2, 'Bob')}
warning

Objects that compare equal must produce the same hash value. If a == b is True but hash(a) != hash(b), sets and dictionaries will behave incorrectly, potentially storing "duplicate" entries or failing to find existing keys.

Using dataclasses for Automatic Equality

The dataclasses module generates __eq__ automatically based on all fields, saving you from writing boilerplate:

from dataclasses import dataclass

@dataclass
class Product:
sku: str
name: str
price: float

p1 = Product("ABC123", "Widget", 9.99)
p2 = Product("ABC123", "Widget", 9.99)
p3 = Product("XYZ789", "Gadget", 19.99)

print(p1 == p2)
print(p1 == p3)

Output:

True
False

For immutable objects that also need to be hashable, use frozen=True:

from dataclasses import dataclass

@dataclass(frozen=True)
class ImmutableProduct:
sku: str
name: str

# Usable as a dictionary key
inventory = {ImmutableProduct("A1", "Widget"): 50}
print(inventory[ImmutableProduct("A1", "Widget")])

Output:

50

Frozen dataclasses automatically generate both __eq__ and __hash__, and they prevent attribute modification after creation.

Summary Table

ConceptPurposeCan Be Overridden
==Value equality operator (calls __eq__)No (it delegates to __eq__)
__eq__Defines what "equal value" means for a classYes
isChecks if two variables reference the same objectNo
__hash__Enables use in sets and as dictionary keysYes

Summary

The == operator calls __eq__ to determine value equality, making __eq__ the method you override to control comparison behavior in custom classes.

The is operator checks memory identity and cannot be overridden, making it suitable only for identity checks like if x is None.

When implementing __eq__, always include an isinstance check and return NotImplemented for incompatible types rather than False.

Remember that overriding __eq__ makes your class unhashable by default, so define __hash__ alongside it if your objects need to work in sets or as dictionary keys.

For simple data-holding classes, use dataclasses to generate correct __eq__ and __hash__ implementations automatically.