Making Objects Behave Like Built-ins
The problem...
You have an Animal object. You print it:
lassie = Animal("Lassie", "dog", 4)
print(lassie)
Output → <__main__.Animal object at 0x10b3c2d50>
A memory address. Useless. You want something readable.
Or you try len(lassie) and get a TypeError. Or you compare two animals with == and Python compares memory addresses instead of actual data.
Your object doesn't speak Python's language yet.
The idea!
Python has a set of special methods — also called dunder methods (double underscore, on both sides) — that define how objects behave with built-in functions and operators. Define them in your class, and your object starts behaving like a native Python type.
__str__ — readable string representation
Called when you use print() or str() on an object.
class Animal:
def __init__(self, name, species, age):
self.name = name
self.species = species
self.age = age
self.status = "available"
def __str__(self):
return f"{self.name} | {self.species} | age: {self.age} | {self.status}"
lassie = Animal("Lassie", "dog", 4)
print(lassie)
Output → Lassie | dog | age: 4 | available
Clean, readable, useful.
__repr__ — developer representation
Called in the Python shell, in debuggers, and when __str__ isn't defined. Should return a string that could recreate the object.
def __repr__(self):
return f"Animal('{self.name}', '{self.species}', {self.age})"
lassie = Animal("Lassie", "dog", 4)
repr(lassie)
Output → Animal('Lassie', 'dog', 4)
Rule of thumb: __str__ is for users, __repr__ is for developers.
__len__ — support for len()
class Shelter:
def __init__(self):
self.animals = []
def add(self, animal):
self.animals.append(animal)
def __len__(self):
return len(self.animals)
shelter = Shelter()
shelter.add(Animal("Lassie", "dog", 4))
shelter.add(Animal("Whiskers", "cat", 2))
print(len(shelter))
Output → 2
__eq__ — support for ==
By default, == compares memory addresses. Define __eq__ to compare actual data.
class Animal:
def __init__(self, name, species, age):
self.name = name
self.species = species
self.age = age
def __eq__(self, other):
return self.name == other.name and self.species == other.species
a = Animal("Lassie", "dog", 4)
b = Animal("Lassie", "dog", 6)
c = Animal("Rex", "dog", 4)
print(a == b) # True — same name and species
print(a == c) # False — different name
__contains__ — support for in
class Shelter:
def __init__(self):
self.animals = []
def add(self, animal):
self.animals.append(animal)
def __contains__(self, animal):
return animal in self.animals
shelter = Shelter()
lassie = Animal("Lassie", "dog", 4)
shelter.add(lassie)
print(lassie in shelter) # True
print(Animal("Rex", "dog", 6) in shelter) # False
The full picture
There are many more dunder methods — __add__ for +, __lt__ for <, __iter__ for loops, __getitem__ for indexing. Each one maps to a built-in operation. Define the ones that make sense for your object.
Heads up!
- Always define
__str__— it makes debugging dramatically easier __repr__is used as fallback when__str__isn't defined__eq__should compare meaningful data — not memory addresses- Dunder methods are called automatically — never call them directly
The mindset shift
Stop thinking: "My object is a custom thing that doesn't work with Python's built-ins."
Start thinking: "My object can speak Python's language — print it, measure it, compare it, search it."
What you should understand now
- Dunder methods define how objects behave with built-in functions and operators
__str__— called byprint(), returns a readable string__repr__— developer representation, fallback for__str____len__— called bylen()__eq__— called by==, compares meaningful data__contains__— called byin