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):
        self.data = []

s1 = Singleton()
s2 = Singleton()
print(s1 is s2)  # True - same instance
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)        # Laptop: $1200
print([p])      # [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 __eq__(self, other):
        return self.grade == other.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)   # True
print(s1 == s2)  # False
print(sorted([s1, s2], key=lambda s: s.grade))  # Works!
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)  # Vector(4, 6)
print(v1 * 3)   # 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
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))            # 3
print(playlist[1])              # Song B
print("Song A" in playlist)     # True
playlist[0] = "New Song"        # Works!

__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))   # 10
print(triple(5))   # 15
print(callable(double))  # 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)  # 30
print(config.unknown)  # No config for unknown
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))     # 0
print(bool(cart))    # False

if cart:
    print("Cart has items")
else:
    print("Cart is empty")  # This prints

__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])  # "start" - works because same hash

# Can use in sets
points = {Point(1, 2), Point(3, 4), Point(1, 2)}
print(len(points))  # 2 - duplicates removed
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