Python Dunder Methods

Make your objects behave like built-in Python types.

What are Dunder Methods?

Dunder methods (short for double underscore) are special methods like __init__ and __str__ that let your classes integrate seamlessly with Python’s syntax and built-in functions.

__new__ and __init__: Object Creation

__new__ creates the object instance before __init__ initializes it. Use __new__ for immutable types or singleton patterns.

class Singleton:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self):
        # CAREFUL: __new__ returns the same object every time, but Python
        # still calls __init__ on it after EVERY Singleton() call. Assigning
        # here would wipe the shared state on each construction.
        if not hasattr(self, "data"):
            self.data = []

s1 = Singleton()
s1.data.append("kept")

s2 = Singleton()
print(s1 is s2)    # same instance?
print(s2.data)     # did state survive the second Singleton() call?

# Without the hasattr guard, __init__ would reset data to [] and print []
Expected Output:
True
['kept']
Tip: __new__ returns the instance, __init__ initializes it

__str__ and __repr__: String Representation

These methods control how objects are displayed as strings.

class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price

    def __str__(self):
        return f"{self.name}: ${self.price}"

    def __repr__(self):
        return f"Product(name={self.name!r}, price={self.price})"

p = Product("Laptop", 1200)
print(p)        # print() uses __str__
print([p])      # a list's repr uses __repr__ on its items
Expected Output:
Laptop: $1200
[Product(name='Laptop', price=1200)]
Rule of thumb: __str__ → user-friendly, __repr__ → developer/debugging

Comparison Methods

Implement rich comparison operators: ==, !=,<, <=, >, >=

class Student:
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade

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

    def __eq__(self, other):
        return self.grade == other.grade

    # Defining __eq__ sets __hash__ to None, making the class unhashable.
    # Restore it explicitly if instances need to go in a set or dict key.
    # See the __hash__ section below for why it must agree with __eq__.
    def __hash__(self):
        return hash(self.grade)

    def __lt__(self, other):
        return self.grade < other.grade

    def __le__(self, other):
        return self.grade <= other.grade

    def __gt__(self, other):
        return self.grade > other.grade

    def __ge__(self, other):
        return self.grade >= other.grade

s1 = Student("Alice", 95)
s2 = Student("Bob", 87)

print(s1 > s2)
print(s1 == s2)

# With __lt__ defined, sorted() needs no key at all
print(sorted([s1, s2]))
Expected Output:
True
False
[Student('Bob', 87), Student('Alice', 95)]
Pro tip: Use @functools.total_ordering decorator to auto-generate comparison methods from just __eq__ and one other!

Arithmetic Operator Overloading

Dunder methods let objects respond to operators like +,-, *, and /.

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

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(1, 2)
v2 = Vector(3, 4)

print(v1 + v2)  # calls __add__
print(v1 * 3)   # calls __mul__
Expected Output:
Vector(4, 6)
Vector(3, 6)

__enter__ and __exit__: Context Managers

Make your objects work with the with statement for automatic resource management.

class DatabaseConnection:
    def __init__(self, db_name):
        self.db_name = db_name
        self.connection = None

    def __enter__(self):
        print(f"Opening connection to {self.db_name}")
        self.connection = f"Connected to {self.db_name}"
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        print(f"Closing connection to {self.db_name}")
        self.connection = None
        return False  # Don't suppress exceptions

with DatabaseConnection("mydb") as db:
    print(db.connection)
# Connection automatically closed after with block
Expected Output:
Opening connection to mydb
Connected to mydb
Closing connection to mydb
Remember: __exit__ receives exception info and can suppress exceptions by returning True

__len__, __getitem__, __setitem__: Container Protocol

Make your objects behave like lists, dictionaries, or other containers.

class PlayList:
    def __init__(self):
        self.songs = []

    def __len__(self):
        return len(self.songs)

    def __getitem__(self, index):
        return self.songs[index]

    def __setitem__(self, index, value):
        self.songs[index] = value

    def __contains__(self, item):
        return item in self.songs

playlist = PlayList()
playlist.songs = ["Song A", "Song B", "Song C"]

print(len(playlist))            # __len__
print(playlist[1])              # __getitem__
print("Song A" in playlist)     # __contains__

playlist[0] = "New Song"        # __setitem__ in action
print(playlist[0])
Expected Output:
3
Song B
True
New Song

__call__: Make Objects Callable

Allow instances to be called like functions using __call__.

class Multiplier:
    def __init__(self, factor):
        self.factor = factor

    def __call__(self, x):
        return x * self.factor

double = Multiplier(2)
triple = Multiplier(3)

print(double(5))
print(triple(5))
print(callable(double))
Expected Output:
10
15
True
Use case: Great for creating function-like objects that maintain state

__getattr__, __setattr__, __delattr__: Attribute Access

Control how attributes are accessed, set, and deleted on your objects.

class Config:
    def __init__(self):
        self._data = {}

    def __getattr__(self, name):
        # Called when attribute not found normally
        return self._data.get(name, f"No config for {name}")

    def __setattr__(self, name, value):
        if name == "_data":
            super().__setattr__(name, value)
        else:
            self._data[name] = value

    def __delattr__(self, name):
        if name in self._data:
            del self._data[name]

config = Config()
config.timeout = 30
config.debug = True
print(config.timeout)
print(config.unknown)   # falls back to __getattr__

del config.timeout      # __delattr__ removes it from _data
print(config.timeout)   # now unknown too
Expected Output:
30
No config for unknown
No config for timeout
Caution: Be careful with __setattr__ to avoid infinite recursion!

__bool__ and __len__: Truthiness

Control how objects behave in boolean contexts and with len().

class ShoppingCart:
    def __init__(self):
        self.items = []

    def __len__(self):
        return len(self.items)

    def __bool__(self):
        return len(self.items) > 0

cart = ShoppingCart()
print(len(cart))
print(bool(cart))

# An empty cart is falsy, so the else branch runs
if cart:
    print("Cart has items")
else:
    print("Cart is empty")
Expected Output:
0
False
Cart is empty

__hash__ and __eq__: Hashable Objects

Make objects usable as dictionary keys and in sets.

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

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

    def __hash__(self):
        return hash((self.x, self.y))

    def __repr__(self):
        return f"Point({self.x}, {self.y})"

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

# Can use as dict keys
positions = {p1: "start"}
print(positions[p2])  # p2 finds p1's entry: equal objects, same hash

# Can use in sets
points = {Point(1, 2), Point(3, 4), Point(1, 2)}
print(len(points))    # duplicates collapse
Expected Output:
start
2
Important: Objects that compare equal must have the same hash value!

Key Takeaways

  • __new__ creates instances, __init__ initializes them
  • Comparison methods enable sorting and ordering of custom objects
  • Context managers with __enter__/__exit__ handle resources cleanly
  • Container methods make objects behave like lists or dictionaries
  • __call__ makes objects callable like functions
  • Attribute access methods provide dynamic behavior
  • __hash__ and __eq__ enable use in sets and as dict keys
  • Well-implemented dunders make your code feel truly Pythonic

Quick Reference

CategoryMethodsPurpose
Creation__new__, __init__Object instantiation
Representation__str__, __repr__String conversion
Comparison__eq__, __lt__, __le__, etc.Ordering and equality
Arithmetic__add__, __sub__, __mul__, etc.Math operations
Context__enter__, __exit__Resource management
Container__len__, __getitem__, __setitem__Sequence behavior
Callable__call__Function-like behavior
Attributes__getattr__, __setattr__Attribute access control
Hashing__hash__, __eq__Dictionary keys, sets
What's Next?

You've learned how to customize object behavior with dunder methods! Now let's explore powerful patterns for handling large datasets efficiently.

  • Iterators - Create custom iteration logic for your objects
  • Generators - Process big data efficiently with lazy evaluation
  • Generator Expressions - Write memory-efficient data pipelines