DocsHub
Object-Oriented Programming

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 values

With 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 transactions

eq — 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 memory

Define __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 object

Return 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 PKR

Or 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 deposited

getitem — 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 transaction

Using 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 PKR

Summary

Dunder methodTriggered 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)

On this page