Dunder Methods
Learn how to use Python's special dunder methods to make your classes behave like built-in types.
Dunder Methods
Dunder stands for double underscore. Dunder methods are special methods like __init__, __str__, __len__ — they have double underscores on both sides.
You already know __init__. But Python has many more. They are what make Python's built-in types work the way they do. When you write len(items), Python calls items.__len__(). When you write a + b, Python calls a.__add__(b). When you print(something), Python calls something.__str__().
By implementing these methods in your own classes, you make them behave like built-in Python types — clean, intuitive, and consistent.
Without dunder methods — the problem
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
account = BankAccount("Ahmad", 10000)
print(account) # <__main__.BankAccount object at 0x7f...> — useless
print(len(account)) # TypeError — len() not supported
account1 = BankAccount("Ahmad", 10000)
account2 = BankAccount("Ahmad", 10000)
print(account1 == account2) # False — compares memory addresses, not valuesWith dunder methods, we fix all of this.
str — human readable string
__str__ defines what you see when you print() an object or call str() on it. Write it for humans — clear and readable:
class BankAccount:
bank_name = "HBL Bank"
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
def __str__(self):
return f"BankAccount(owner='{self.owner}', balance={self.balance:,} PKR)"
account = BankAccount("Ahmad", 10000)
print(account) # BankAccount(owner='Ahmad', balance=10,000 PKR)
print(str(account)) # BankAccount(owner='Ahmad', balance=10,000 PKR)repr — developer string
__repr__ is for developers — it should show exactly how to recreate the object. When you type an object in the Python shell without print(), it shows __repr__:
class BankAccount:
bank_name = "HBL Bank"
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
def __str__(self):
return f"BankAccount(owner='{self.owner}', balance={self.balance:,} PKR)"
def __repr__(self):
return f"BankAccount(owner={self.owner!r}, balance={self.balance!r})"
account = BankAccount("Ahmad", 10000)
print(str(account)) # BankAccount(owner='Ahmad', balance=10,000 PKR)
print(repr(account)) # BankAccount(owner='Ahmad', balance=10000)The rule of thumb:
__str__— readable for end users."Ahmad's account: 10,000 PKR"__repr__— unambiguous for developers.BankAccount(owner='Ahmad', balance=10000)
If you only define one, define __repr__. Python falls back to __repr__ when __str__ is not defined — but not the other way around.
len — support len()
__len__ lets you use len() on your object. For a bank account, a meaningful length could be the number of transactions:
class BankAccount:
bank_name = "HBL Bank"
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
self.transactions = []
def deposit(self, amount):
if amount > 0:
self.balance += amount
self.transactions.append(("deposit", amount))
def withdraw(self, amount):
if 0 < amount <= self.balance:
self.balance -= amount
self.transactions.append(("withdrawal", amount))
def __len__(self):
return len(self.transactions)
def __str__(self):
return f"BankAccount(owner='{self.owner}', balance={self.balance:,} PKR)"
account = BankAccount("Ahmad", 10000)
account.deposit(5000)
account.deposit(3000)
account.withdraw(2000)
print(len(account)) # 3 — number of transactionseq — equality comparison
Without __eq__, two accounts with the same data are not equal — Python compares memory addresses:
account1 = BankAccount("Ahmad", 10000)
account2 = BankAccount("Ahmad", 10000)
print(account1 == account2) # False — different objects in memoryDefine __eq__ to compare by values:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
self.account_number = f"HBL-{id(self) % 100000:05d}"
def __eq__(self, other):
if not isinstance(other, BankAccount):
return NotImplemented
return self.account_number == other.account_number
def __str__(self):
return f"BankAccount(owner='{self.owner}', balance={self.balance:,} PKR)"
account1 = BankAccount("Ahmad", 10000)
account2 = BankAccount("Ahmad", 10000) # same data, different object
account3 = account1 # same object
print(account1 == account2) # False — different account numbers
print(account1 == account3) # True — literally the same objectReturn NotImplemented (not False) when the other object is not the right type. This tells Python to try the comparison the other way around — letting the other object's __eq__ handle it.
lt, gt, le, ge — comparisons
These let you use <, >, <=, >= to compare objects. For bank accounts, compare by balance:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
def __eq__(self, other):
if not isinstance(other, BankAccount):
return NotImplemented
return self.balance == other.balance
def __lt__(self, other):
if not isinstance(other, BankAccount):
return NotImplemented
return self.balance < other.balance
def __le__(self, other):
if not isinstance(other, BankAccount):
return NotImplemented
return self.balance <= other.balance
def __gt__(self, other):
if not isinstance(other, BankAccount):
return NotImplemented
return self.balance > other.balance
def __ge__(self, other):
if not isinstance(other, BankAccount):
return NotImplemented
return self.balance >= other.balance
def __str__(self):
return f"{self.owner}: {self.balance:,} PKR"
account1 = BankAccount("Ahmad", 10000)
account2 = BankAccount("Sara", 25000)
account3 = BankAccount("Omar", 10000)
print(account1 < account2) # True — 10000 < 25000
print(account2 > account1) # True — 25000 > 10000
print(account1 == account3) # True — 10000 == 10000
print(account1 <= account3) # True — 10000 <= 10000
# now sorting works too
accounts = [account2, account1, account3]
accounts.sort()
for acc in accounts:
print(acc)Output:
True
True
True
True
Ahmad: 10,000 PKR
Omar: 10,000 PKR
Sara: 25,000 PKROr use @functools.total_ordering — define __eq__ and one of the comparison methods, and it fills in the rest automatically. We covered this in the Functional Programming file.
add — the + operator
Let's say you want to merge two accounts. Define __add__:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
def __add__(self, other):
if not isinstance(other, BankAccount):
return NotImplemented
combined_owner = f"{self.owner} & {other.owner}"
combined_balance = self.balance + other.balance
return BankAccount(combined_owner, combined_balance)
def __str__(self):
return f"BankAccount(owner='{self.owner}', balance={self.balance:,} PKR)"
account1 = BankAccount("Ahmad", 10000)
account2 = BankAccount("Sara", 25000)
merged = account1 + account2
print(merged)
# BankAccount(owner='Ahmad & Sara', balance=35,000 PKR)contains — the in operator
Define __contains__ to support in checks:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
self.transactions = []
def deposit(self, amount):
if amount > 0:
self.balance += amount
self.transactions.append(("deposit", amount))
def __contains__(self, amount):
"""Check if a specific amount appears in transactions."""
return any(t[1] == amount for t in self.transactions)
def __str__(self):
return f"BankAccount(owner='{self.owner}', balance={self.balance:,} PKR)"
account = BankAccount("Ahmad", 10000)
account.deposit(5000)
account.deposit(3000)
print(5000 in account) # True — 5000 was deposited
print(9999 in account) # False — 9999 was never depositedgetitem — index access
Define __getitem__ to support account[0] style access:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
self.transactions = []
def deposit(self, amount):
if amount > 0:
self.balance += amount
self.transactions.append(("deposit", amount))
def withdraw(self, amount):
if 0 < amount <= self.balance:
self.balance -= amount
self.transactions.append(("withdrawal", amount))
def __getitem__(self, index):
return self.transactions[index]
def __len__(self):
return len(self.transactions)
def __str__(self):
return f"BankAccount(owner='{self.owner}', balance={self.balance:,} PKR)"
account = BankAccount("Ahmad", 10000)
account.deposit(5000)
account.deposit(3000)
account.withdraw(2000)
print(account[0]) # ('deposit', 5000)
print(account[1]) # ('deposit', 3000)
print(account[-1]) # ('withdrawal', 2000) — negative indexing works too
# slicing works too
print(account[0:2]) # [('deposit', 5000), ('deposit', 3000)]iter and next — making objects iterable
Define __iter__ and __next__ to make your object work with for loops:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
self.transactions = []
self._index = 0
def deposit(self, amount):
if amount > 0:
self.balance += amount
self.transactions.append(("deposit", amount))
def withdraw(self, amount):
if 0 < amount <= self.balance:
self.balance -= amount
self.transactions.append(("withdrawal", amount))
def __iter__(self):
self._index = 0
return self
def __next__(self):
if self._index >= len(self.transactions):
raise StopIteration
transaction = self.transactions[self._index]
self._index += 1
return transaction
def __str__(self):
return f"BankAccount(owner='{self.owner}', balance={self.balance:,} PKR)"
account = BankAccount("Ahmad", 10000)
account.deposit(5000)
account.deposit(3000)
account.withdraw(2000)
for t_type, amount in account:
symbol = "+" if t_type == "deposit" else "-"
print(f" {symbol} {amount:,} PKR ({t_type})")Output:
+ 5,000 PKR (deposit)
+ 3,000 PKR (deposit)
- 2,000 PKR (withdrawal)bool — truth value
Define __bool__ to control what if account: evaluates to. For a bank account, an account with balance is truthy:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
def __bool__(self):
return self.balance > 0
def __str__(self):
return f"BankAccount(owner='{self.owner}', balance={self.balance:,} PKR)"
account1 = BankAccount("Ahmad", 10000)
account2 = BankAccount("Sara", 0)
if account1:
print(f"{account1.owner}'s account is active.")
if not account2:
print(f"{account2.owner}'s account is empty.")Output:
Ahmad's account is active.
Sara's account is empty.The complete BankAccount with dunder methods
from functools import total_ordering
@total_ordering
class BankAccount:
bank_name = "HBL Bank"
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
self.transactions = []
self._account_number = f"HBL-{id(self) % 100000:05d}"
self._index = 0
# --- Core methods ---
def deposit(self, amount):
if amount > 0:
self.balance += amount
self.transactions.append(("deposit", amount))
print(f"Deposited {amount:,} PKR → Balance: {self.balance:,} PKR")
def withdraw(self, amount):
if amount <= 0:
print("Invalid amount.")
elif amount > self.balance:
print(f"Insufficient balance. Available: {self.balance:,} PKR")
else:
self.balance -= amount
self.transactions.append(("withdrawal", amount))
print(f"Withdrawn {amount:,} PKR → Balance: {self.balance:,} PKR")
# --- Dunder methods ---
def __str__(self):
return (
f"BankAccount | {self._account_number} | "
f"Owner: {self.owner} | "
f"Balance: {self.balance:,} PKR"
)
def __repr__(self):
return f"BankAccount(owner={self.owner!r}, balance={self.balance!r})"
def __len__(self):
return len(self.transactions)
def __bool__(self):
return self.balance > 0
def __eq__(self, other):
if not isinstance(other, BankAccount):
return NotImplemented
return self._account_number == other._account_number
def __lt__(self, other):
if not isinstance(other, BankAccount):
return NotImplemented
return self.balance < other.balance
def __add__(self, other):
if not isinstance(other, BankAccount):
return NotImplemented
return BankAccount(
f"{self.owner} & {other.owner}",
self.balance + other.balance
)
def __contains__(self, amount):
return any(t[1] == amount for t in self.transactions)
def __getitem__(self, index):
return self.transactions[index]
def __iter__(self):
self._index = 0
return self
def __next__(self):
if self._index >= len(self.transactions):
raise StopIteration
transaction = self.transactions[self._index]
self._index += 1
return transactionUsing it all:
account1 = BankAccount("Ahmad", 10000)
account2 = BankAccount("Sara", 25000)
account3 = BankAccount("Omar", 5000)
account1.deposit(5000)
account1.deposit(3000)
account1.withdraw(2000)
# __str__
print(account1)
# __repr__
print(repr(account1))
# __len__
print(f"Transactions: {len(account1)}")
# __bool__
print(f"Has balance: {bool(account1)}")
# __eq__ and __lt__ via total_ordering
print(f"account1 > account3: {account1 > account3}")
print(f"account2 > account1: {account2 > account1}")
# __add__
merged = account1 + account3
print(merged)
# __contains__
print(f"5000 in account1: {5000 in account1}")
print(f"9999 in account1: {9999 in account1}")
# __getitem__
print(f"First transaction: {account1[0]}")
print(f"Last transaction: {account1[-1]}")
# __iter__
print("\nTransaction history:")
for t_type, amount in account1:
symbol = "+" if t_type == "deposit" else "-"
print(f" {symbol} {amount:,} PKR")
# sort accounts by balance using __lt__
accounts = [account2, account1, account3]
accounts.sort()
print("\nAccounts by balance:")
for acc in accounts:
print(f" {acc.owner}: {acc.balance:,} PKR")Output:
BankAccount | HBL-12345 | Owner: Ahmad | Balance: 16,000 PKR
BankAccount(owner='Ahmad', balance=16000)
Transactions: 3
Has balance: True
account1 > account3: True
account2 > account1: True
BankAccount | HBL-67890 | Owner: Ahmad & Omar | Balance: 21,000 PKR
5000 in account1: True
9999 in account1: False
First transaction: ('deposit', 5000)
Last transaction: ('withdrawal', 2000)
Transaction history:
+ 5,000 PKR
+ 3,000 PKR
- 2,000 PKR
Accounts by balance:
Omar: 5,000 PKR
Ahmad: 16,000 PKR
Sara: 25,000 PKRSummary
| Dunder method | Triggered by |
|---|---|
__init__ | BankAccount(owner, balance) |
__str__ | print(account) / str(account) |
__repr__ | repr(account) / shell display |
__len__ | len(account) |
__bool__ | if account: |
__eq__ | account1 == account2 |
__lt__ | account1 < account2 |
__add__ | account1 + account2 |
__contains__ | 5000 in account |
__getitem__ | account[0] |
__iter__ | for t in account: |
__next__ | next(account) |