DEV Community

Cover image for Python Magic Methods and Dunder Methods: How Python Objects Really Work
shalini
shalini

Posted on

Python Magic Methods and Dunder Methods: How Python Objects Really Work

If you've been using Python for a while, you've probably seen methods like __init__(), __str__(), __len__(), and __eq__().

They may look a little unusual because of the double underscores.

These are called magic methods or dunder methods.

But the interesting part isn't the naming convention.

The real value of dunder methods is understanding how Python objects interact with the language itself.

When you write:

len(order)
Enter fullscreen mode Exit fullscreen mode

or:

print(order)
Enter fullscreen mode Exit fullscreen mode

or:

item = products[0]
Enter fullscreen mode Exit fullscreen mode

Python can internally use special methods defined by the object's class.

So the basic idea is:

Your code → Python operation → Special method → Object behavior

Once you understand this model, many Python features that seem like “magic” become much easier to understand.

What Exactly Is a Dunder Method?

The word dunder comes from double underscore.

For example:

__init__
__str__
__repr__
__len__
__getitem__
__eq__
__add__
__iter__
__next__
__enter__
__exit__
Enter fullscreen mode Exit fullscreen mode

These methods are part of Python's data model.

You normally don't call them directly.

Instead, Python invokes them when you perform a corresponding operation.

For example:

class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price
Enter fullscreen mode Exit fullscreen mode

When you create:

product = Product("Laptop", 75000)
Enter fullscreen mode Exit fullscreen mode

Python uses __init__() to initialize the object.

Similarly:

print(product)
Enter fullscreen mode Exit fullscreen mode

can trigger:

product.__str__()
Enter fullscreen mode Exit fullscreen mode

And:

len(products)
Enter fullscreen mode Exit fullscreen mode

can trigger:

products.__len__()
Enter fullscreen mode Exit fullscreen mode

That's the important concept.

Dunder methods allow custom objects to behave like native Python objects.

Why Should Full Stack Developers Care?

You can write many Python programs without defining custom magic methods.

But once you start building real applications, custom objects appear everywhere.

Think about a typical backend:

Frontend
   ↓
REST / GraphQL API
   ↓
Python Application
   ↓
Service Layer
   ↓
Repository / ORM
   ↓
Database
Enter fullscreen mode Exit fullscreen mode

Objects move through all of these layers.

You may have an Order object, a User object, an API response object, a database entity, a custom collection, or a resource manager.

Instead of exposing internal implementation details, dunder methods can make these objects behave naturally.

For example:

len(order)
Enter fullscreen mode Exit fullscreen mode

could return the number of products in the order.

And:

print(order)
Enter fullscreen mode Exit fullscreen mode

could display a useful representation.

That's where magic methods become useful as a design tool, rather than just an interview topic.

Python's Data Model Is the Important Part

The biggest mistake is trying to memorize every dunder method.

It's much more useful to understand the relationship between Python operations and protocols.

For example:

order1 + order2
Enter fullscreen mode Exit fullscreen mode

can involve:

order1.__add__(order2)
Enter fullscreen mode Exit fullscreen mode

And:

collection[0]
Enter fullscreen mode Exit fullscreen mode

can involve:

collection.__getitem__(0)
Enter fullscreen mode Exit fullscreen mode

While:

for item in collection:
    ...
Enter fullscreen mode Exit fullscreen mode

uses Python's iteration protocol.

You can think of it like this:

Python expression
      ↓
Language operation
      ↓
Python protocol
      ↓
Special method
      ↓
Custom object behavior
Enter fullscreen mode Exit fullscreen mode

This protocol-based design is one of the reasons Python APIs can feel so natural.

__init__() — Initializing Objects

__init__() is probably the first dunder method most Python developers encounter.

class User:
    def __init__(self, username, email):
        self.username = username
        self.email = email
Enter fullscreen mode Exit fullscreen mode

Now:

user = User("swathi", "user@example.com")
Enter fullscreen mode Exit fullscreen mode

initializes the object's attributes.

One important detail:

__init__() does not technically create the object.

Object creation is associated with __new__(), while __init__() initializes the instance after it has been created.

For everyday application development, you will usually work with __init__().

__new__() becomes more relevant for advanced object creation, immutable types, metaprogramming, and specialized patterns.

__str__() — Making Objects Readable

Suppose you have:

class Employee:
    def __init__(self, name, role):
        self.name = name
        self.role = role

    def __str__(self):
        return f"{self.name} - {self.role}"
Enter fullscreen mode Exit fullscreen mode

Now:

employee = Employee("Rahul", "Backend Engineer")

print(employee)
Enter fullscreen mode Exit fullscreen mode

can produce:

Rahul - Backend Engineer
Enter fullscreen mode Exit fullscreen mode

That's much more useful than seeing a default object representation.

__str__() is generally intended for a human-readable representation.

Be careful about what you expose here.

Sensitive information such as passwords, API keys, access tokens, or authentication data should never be included.

__repr__() — A Developer-Friendly Representation

__repr__() has a different purpose.

It should generally provide a representation that is useful when developers inspect an object.

For example:

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

    def __repr__(self):
        return (
            f"Product(product_id={self.product_id!r}, "
            f"price={self.price!r})"
        )
Enter fullscreen mode Exit fullscreen mode

When debugging or testing, this kind of representation can make objects much easier to understand.

A simple way to remember it:

__str__()  → Human-readable
__repr__() → Developer-oriented
Enter fullscreen mode Exit fullscreen mode

Defining Equality with __eq__()

What should happen when you write:

user1 == user2
Enter fullscreen mode Exit fullscreen mode

For custom classes, you can define that behavior using __eq__().

For example:

class User:
    def __init__(self, user_id, email):
        self.user_id = user_id
        self.email = email

    def __eq__(self, other):
        if not isinstance(other, User):
            return NotImplemented

        return self.user_id == other.user_id
Enter fullscreen mode Exit fullscreen mode

Here, users are considered equal when their IDs match.

Notice the use of:

return NotImplemented
Enter fullscreen mode Exit fullscreen mode

when the other object isn't a User.

That's different from simply returning False.

It tells Python that the comparison isn't supported for that operand type, allowing Python to consider the appropriate reflected operation.

For database-backed objects, equality deserves particular attention.

Two objects might represent the same database record while having different temporary state.

So equality should be based on a meaningful and stable identity when appropriate.

Comparison Operators

Python also provides dunder methods for ordering:

__lt__  → <
__le__  → <=
__gt__  → >
__ge__  → >=
Enter fullscreen mode Exit fullscreen mode

For example:

class Invoice:
    def __init__(self, amount):
        self.amount = amount

    def __lt__(self, other):
        return self.amount < other.amount
Enter fullscreen mode Exit fullscreen mode

Now:

invoice1 < invoice2
Enter fullscreen mode Exit fullscreen mode

can compare the invoice amounts.

Python's functools.total_ordering can sometimes reduce the amount of comparison code you need to write.

However, use it when it actually improves maintainability rather than automatically using it everywhere.

Operator Overloading with Dunder Methods

Python objects can participate in arithmetic operations too.

For example:

__add__       → +
__sub__       → -
__mul__       → *
__truediv__   → /
__floordiv__  → //
__mod__       → %
__pow__       → **
Enter fullscreen mode Exit fullscreen mode

Consider a simple Money object:

class Money:
    def __init__(self, amount):
        self.amount = amount

    def __add__(self, other):
        if not isinstance(other, Money):
            return NotImplemented

        return Money(self.amount + other.amount)
Enter fullscreen mode Exit fullscreen mode

Now:

total = Money(100) + Money(250)
Enter fullscreen mode Exit fullscreen mode

can return another Money object.

This is useful when the operator has an obvious meaning.

But don't overload operators simply because you can.

Something like:

invoice + customer
Enter fullscreen mode Exit fullscreen mode

would probably make your application harder to understand.

A good rule is:

If the operator doesn't have an intuitive meaning, use an explicit method instead.

Making Custom Objects Behave Like Collections

One of my favorite practical uses of dunder methods is building custom collections.

Consider:

class Cart:
    def __init__(self, items):
        self.items = items

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

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

    def __contains__(self, item):
        return item in self.items
Enter fullscreen mode Exit fullscreen mode

Now you can write:

cart = Cart(["Laptop", "Mouse", "Keyboard"])

print(len(cart))
print(cart[0])
print("Mouse" in cart)
Enter fullscreen mode Exit fullscreen mode

The object feels like a normal Python collection.

This pattern can be useful for:

API response wrappers
Repository results
Pagination objects
Custom query collections
Cached datasets
Domain-specific collections
Enter fullscreen mode Exit fullscreen mode

The advantage is that developers can use familiar Python syntax.

Iteration with __iter__() and __next__()

Python's iteration protocol is another important part of the data model.

For example:

class Numbers:
    def __init__(self, values):
        self.values = values

    def __iter__(self):
        return iter(self.values)
Enter fullscreen mode Exit fullscreen mode

Now:

numbers = Numbers([10, 20, 30])

for number in numbers:
    print(number)
Enter fullscreen mode Exit fullscreen mode

works naturally.

Custom iterators can also implement __next__() to control how values are produced.

But there's an important practical consideration.

You don't always need to manually implement iterator state.

Sometimes a generator is simpler:

def read_records(records):
    for record in records:
        yield record
Enter fullscreen mode Exit fullscreen mode

Generators provide lazy evaluation, which can be useful when processing large datasets.

For example:

Database results
       ↓
Large files
       ↓
Log streams
       ↓
API pagination
       ↓
Event streams
Enter fullscreen mode Exit fullscreen mode

Instead of loading everything into memory at once, values can be processed as they are needed.

Context Managers with __enter__() and __exit__()

You've probably written:

with open("data.txt") as file:
    content = file.read()
Enter fullscreen mode Exit fullscreen mode

The with statement works through Python's context manager protocol.

The key methods are:

__enter__()
__exit__()
Enter fullscreen mode Exit fullscreen mode

You can create your own context manager too:

class DatabaseTransaction:
    def __enter__(self):
        print("Transaction started")
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type:
            print("Rollback")
        else:
            print("Commit")
Enter fullscreen mode Exit fullscreen mode

Then:

with DatabaseTransaction():
    # database operations
    pass
Enter fullscreen mode Exit fullscreen mode

This pattern can be useful for database transactions, resource management, connection handling, tracing, locks, and temporary configuration changes.

For simpler cases, contextlib.contextmanager can often provide a cleaner implementation.

__call__() — Turning Objects into Functions

Another useful dunder method is:

__call__()
Enter fullscreen mode Exit fullscreen mode

It allows an object instance to be called like a function.

For example:

class Validator:
    def __init__(self, minimum):
        self.minimum = minimum

    def __call__(self, value):
        return value >= self.minimum
Enter fullscreen mode Exit fullscreen mode

Now:

validate = Validator(18)

print(validate(25))
Enter fullscreen mode Exit fullscreen mode

The object behaves like a function while still storing configuration.

This can be useful for:

Validators
Middleware
Callbacks
Reusable business rules
Strategy objects
Configurable components
Enter fullscreen mode Exit fullscreen mode

Synchronous vs Asynchronous Context Managers

Modern Python applications increasingly use asynchronous programming.

For synchronous code:

__enter__()
__exit__()
Enter fullscreen mode Exit fullscreen mode

are used.

For asynchronous code:

__aenter__()
__aexit__()
Enter fullscreen mode Exit fullscreen mode

are used.

For example:

class AsyncResource:
    async def __aenter__(self):
        await self.connect()
        return self

    async def __aexit__(self, exc_type, exc, traceback):
        await self.close()
Enter fullscreen mode Exit fullscreen mode

This becomes useful when working with asynchronous database clients, HTTP clients, background processing, and event-driven systems.

Understanding these protocols becomes increasingly important as Python backend development moves toward asynchronous architectures.

Where Do Dunder Methods Appear in Web Development?

You may not always notice them, but dunder methods are used throughout Python frameworks and libraries.

A typical application might look like:

Frontend
   ↓
REST / GraphQL
   ↓
Python Backend
   ↓
Domain Objects
   ↓
Services
   ↓
Repositories / ORM
   ↓
Database
Enter fullscreen mode Exit fullscreen mode

At different levels, custom objects may need to support operations such as:

len(order)
Enter fullscreen mode Exit fullscreen mode

or:

response["user"]
Enter fullscreen mode Exit fullscreen mode

or:

with resource:
    ...
Enter fullscreen mode Exit fullscreen mode

The goal isn't to create dozens of magic methods.

The goal is to make objects behave consistently with Python's conventions.

Dunder Methods in AI and Agentic Python

Dunder methods aren't limited to traditional web development.

They can also be useful when building AI-powered applications and agentic systems.

Imagine an AI tool represented by an object:

class Tool:
    def __init__(self, name, handler):
        self.name = name
        self.handler = handler

    def __call__(self, **kwargs):
        return self.handler(**kwargs)

    def __repr__(self):
        return f"Tool(name={self.name!r})"
Enter fullscreen mode Exit fullscreen mode

Now the object can behave like a callable tool while still carrying configuration and metadata.

This type of design can be useful for:

AI tools
      ↓
Agent workflows
      ↓
Task executors
      ↓
Retrieval components
      ↓
API clients
      ↓
Workflow nodes
Enter fullscreen mode Exit fullscreen mode

For developers learning AI and Agentic with Python, understanding Python's object model becomes valuable when moving from simple scripts to reusable components and production systems.

Performance: Don't Hide Expensive Operations

Dunder methods themselves aren't automatically slow.

The implementation determines the performance.

This is straightforward:

def __len__(self):
    return len(self.items)
Enter fullscreen mode Exit fullscreen mode

But this is potentially problematic:

def __len__(self):
    return expensive_database_query()
Enter fullscreen mode Exit fullscreen mode

When someone writes:

len(object)
Enter fullscreen mode Exit fullscreen mode

they generally expect a lightweight operation.

The same principle applies to:

__str__()
__repr__()
__eq__()
Enter fullscreen mode Exit fullscreen mode

Avoid unexpected database queries, network requests, expensive calculations, or mutations inside protocol methods.

A good rule is:

Implicit operations should remain predictable.

Common Dunder Method Mistakes

The first mistake is using magic methods everywhere.

Not every business operation needs operator overloading.

This is usually clearer:

invoice.calculate_total()
Enter fullscreen mode Exit fullscreen mode

than inventing an unusual operator.

Another mistake is putting I/O inside __repr__().

Debugging an object should not unexpectedly trigger a database query.

Also remember that __str__() must return a string.

For example, this is incorrect:

def __str__(self):
    return self
Enter fullscreen mode Exit fullscreen mode

Another common mistake is ignoring NotImplemented when implementing binary operations or comparisons.

Finally, don't confuse __new__() with __init__().

You usually don't need to override __new__() unless you're solving a specific object-creation problem.

Testing Dunder Methods

If your application depends on custom object behavior, test that behavior directly.

For example:

def test_order_length():
    order = Order(["Laptop", "Mouse"])
    assert len(order) == 2
Enter fullscreen mode Exit fullscreen mode

You can also test:

str(order)
repr(order)
order == another_order
order[0]
"Mouse" in order
Enter fullscreen mode Exit fullscreen mode

The important thing is to test the behavior developers and users actually rely on.

Dunder Methods in Python Interviews

Magic methods are popular interview topics because they reveal whether someone understands Python beyond basic syntax.

You may be asked:

What are magic methods?
Why are they called dunder methods?
__new__() vs __init__()?
__str__() vs __repr__()?
How does operator overloading work?
Why use NotImplemented?
How does iteration work?
__iter__() vs __next__()?
How do context managers work?
When should operator overloading be avoided?
Enter fullscreen mode Exit fullscreen mode

A strong answer shouldn't simply define the methods.

Explain why and when you would use them.

That's what demonstrates practical understanding.

A Practical Learning Path

If you're learning Python Full Stack development, dunder methods fit naturally into a broader progression:

Python Fundamentals
        ↓
Object-Oriented Python
        ↓
Python Data Model
        ↓
Magic / Dunder Methods
        ↓
REST APIs
        ↓
Database & ORM
        ↓
Frontend Integration
        ↓
Testing
        ↓
Deployment
        ↓
Cloud & DevOps
        ↓
AI Integration
        ↓
Agentic Python Applications
Enter fullscreen mode Exit fullscreen mode

Learning the data model before jumping directly into frameworks can make many framework behaviors easier to understand.

You start seeing that many “framework features” are actually built on Python's existing language protocols.

Python + AI: Why the Fundamentals Still Matter

Python has become one of the most widely used languages for AI development.

But adding AI to an application doesn't eliminate the need for traditional software engineering.

A production AI application still needs:

Clean architecture
      ↓
Authentication
      ↓
Authorization
      ↓
API design
      ↓
Database management
      ↓
Error handling
      ↓
Testing
      ↓
Observability
      ↓
Security
      ↓
Performance
      ↓
Deployment
Enter fullscreen mode Exit fullscreen mode

That's why learning Full Stack Python Online Training development alongside AI and Agentic Python concepts can provide a broader engineering foundation.

The AI layer sits on top of the application layer.

Understanding both makes it easier to build systems that are not only intelligent, but also maintainable and production-ready.

The Main Takeaway

Python magic methods can look mysterious when you first encounter them.

But once you understand Python's data model, they become much easier to reason about.

The flow is essentially:

Python operation → Protocol → Dunder method → Custom behavior

You don't need to memorize every special method.

Instead, understand the protocols and learn which dunder method Python uses for a particular operation.

Use them when they make your objects more natural to work with.

Avoid them when they make business logic harder to understand.

And keep implicit operations lightweight and predictable.

That's the difference between using dunder methods because they're clever and using them because they're good Python design.

If you're building toward Python Full Stack development, this foundation becomes useful across APIs, databases, frameworks, asynchronous applications, testing, and modern AI-powered systems.

Top comments (0)