DEV Community

Cover image for Python Data Model - Part 2: Protocols and Special Methods
Bruno Teixeira Lopes
Bruno Teixeira Lopes

Posted on

Python Data Model - Part 2: Protocols and Special Methods

🌐 Leia a versĂŁo em portuguĂȘs deste artigo aqui.

1. From Part One to Language Protocols

In Part 1, we established the foundation of Python's data model: objects possess identity, type, and value; names hold references to those objects; mutability determines what changes can occur without replacing the object; and containers store references to other objects.

Now we advance another layer.

The type of an object does not merely determine what values it can represent. It also determines which operations that object supports: whether it has a size, whether it can be traversed, compared, indexed, called as a function, used in a with statement, and so forth (PYTHON SOFTWARE FOUNDATION, 2026a).

This is where special methods come in.

An important observation before we continue: in Part 1 we used memory address as a mental model for identity. More rigorously, Python guarantees that an object's identity remains stable during its existence. In CPython specifically, id(obj) corresponds to the memory address of the object; this is a detail of the reference implementation, not a language guarantee (PYTHON SOFTWARE FOUNDATION, 2026a).

The same distinction will be important when we discuss garbage collection and finalization: Python specifies the language's behavior; CPython is one implementation of that behavior.

Reference Version

The behaviors and references in this article were reviewed based on the official documentation of Python 3.14.7. CPython-specific details will be explicitly identified.


2. What Are Special Methods

In the official documentation, names like __len__, __iter__, and __add__ are called special methods. In the community, you will also commonly find the terms magic methods or dunder methods (dunder comes from double underscore because of the __name__ pattern).

They allow classes defined by us to participate in operations that are part of the language's own syntax and built-in functions.

For example:

You write Behavior Python must resolve Related methods
repr(obj) official representation __repr__
str(obj) / print(obj) informal representation __str__, with fallback to __repr__
len(obj) size __len__
obj[key] subscription __getitem__
obj[key] = value subscription assignment __setitem__
for item in obj iteration __iter__, __next__
a == b equality comparison __eq__
a + b arithmetic operation __add__, possibly __radd__
obj(...) call __call__
with obj: context management __enter__, __exit__

This table is a conceptual map, not a literal line-by-line translation of what the interpreter executes. It is precisely this difference that we need to understand.

2.1. Operation First, Method Second

Consider len().

When we write:

len([10, 20, 30])
Enter fullscreen mode Exit fullscreen mode

we get:

3
Enter fullscreen mode Exit fullscreen mode

The same happens with a string:

len("Python")
# 6
Enter fullscreen mode Exit fullscreen mode

But len() does not need to possess a large sequence of checks like:

# model that DOES NOT represent how len() works

if isinstance(obj, list):
    ...
elif isinstance(obj, str):
    ...
elif isinstance(obj, tuple):
    ...
Enter fullscreen mode Exit fullscreen mode

A class created by us can also participate in this behavior:

class Box:
    def __len__(self) -> int:
        return 3


box = Box()

len(box)
# 3
Enter fullscreen mode Exit fullscreen mode

Box does not inherit from list, tuple, or str.

What matters is that its type offers the behavior expected by the size operation.

We can think about the path conceptually like this:

len(box)
    ↓
size operation
    ↓
corresponding protocol
    ↓
__len__
Enter fullscreen mode Exit fullscreen mode

This kind of behavioral contract is what we will call a protocol throughout the article.

Python does not need to know in advance all the classes that might exist. The class informs the language which operations it supports through the appropriate special methods.

2.2. Special Method Lookup Is Different

There is, however, a fundamental nuance.

A useful first approximation is to imagine:

len(obj) ≈ type(obj).__len__(obj)
Enter fullscreen mode Exit fullscreen mode

The symbol ≈ is intentional. This is a mental model, not a literal rewrite of the code executed by the interpreter.

Implicit invocations of special methods have their own lookup rules. For user-defined classes, Python looks for these methods in the type of the object, not simply in the attribute dictionary of the instance (PYTHON SOFTWARE FOUNDATION, 2026b).

See the difference:

class Box:
    def __len__(self) -> int:
        return 3


box = Box()

box.__len__ = lambda: 99
Enter fullscreen mode Exit fullscreen mode

Now we have:

box.__len__()
# 99
Enter fullscreen mode Exit fullscreen mode

The call above is explicit. We are accessing the __len__ attribute of the instance normally.

But:

len(box)
# 3
Enter fullscreen mode Exit fullscreen mode

still uses the method defined in Box.

We can observe the approximate model:

type(box).__len__(box)
# 3
Enter fullscreen mode Exit fullscreen mode

Therefore:

box.__len__()
Enter fullscreen mode Exit fullscreen mode

and:

len(box)
Enter fullscreen mode Exit fullscreen mode

do not have exactly the same resolution mechanism.

Important Detail

Implicit lookup of special methods generally ignores attributes defined directly on the instance and also avoids part of the normal __getattribute__ mechanism.

For this reason, so that operations like len(obj) work consistently, the corresponding special method must be defined on the class, that is, on the object's type (PYTHON SOFTWARE FOUNDATION, 2026b).

This rule explains why dunder methods should not be viewed simply as ordinary methods with strange names. They are points of integration between our types and the language's data model.

2.3. A Map of Main Groups

We do not need to memorize all existing special methods. It is more useful to group them by the behavior they allow implementing:

Category Main Methods Official Documentation
Creation and initialization __new__, __init__ Basic customization
Finalization __del__ object.del
Representation __repr__, __str__, __format__ Basic customization
Attribute access __getattribute__, __getattr__, __setattr__ Customizing attribute access
Descriptors __get__, __set__, __delete__ Implementing Descriptors
Containers and sequences __len__, __getitem__, __setitem__, __contains__ Emulating container types
Iteration __iter__, __next__ Iterator Types
Comparisons __eq__, __lt__, __gt__ etc. Basic customization
Numeric operations __add__, __mul__, __rmul__, __abs__ etc. Emulating numeric types
Callability __call__ Emulating callable objects
Context managers __enter__, __exit__ With Statement Context Managers

The table is deliberately incomplete. The goal is not to transform this part of the series into a catalog of dunders, but to understand why they exist and how different methods form coherent behaviors.

2.4. Creation: __new__ and __init__

One of the first important distinctions appears in object creation itself.

It is common to say that "__init__ creates the object", but that is not exactly what happens.

Conceptually:

Class(...)
    ↓
__new__(cls, ...)
    ↓
new instance
    ↓
__init__(instance, ...)
    ↓
object ready for the caller
Enter fullscreen mode Exit fullscreen mode

__new__ is called to create and return the new instance.

__init__ receives an instance that already exists and performs its initialization or customization (PYTHON SOFTWARE FOUNDATION, 2026c).

Consider:

from typing import Self


class Connection:
    def __new__(cls, host: str) -> Self:
        print("1. __new__ created the instance")
        instance = super().__new__(cls)
        return instance

    def __init__(self, host: str) -> None:
        print("2. __init__ initialized the instance")
        self.host = host


Connection("db.local")

Enter fullscreen mode Exit fullscreen mode

The result is:

1. __new__ created the instance
2. __init__ initialized the instance

Enter fullscreen mode Exit fullscreen mode

Note that __new__ receives cls, not self.

This occurs because the instance is still being created. The cls argument informs which class was requested, allowing the method to return an appropriate instance even when inheritance is involved.

In the most common implementation:

super().__new__(cls)
Enter fullscreen mode Exit fullscreen mode

we delegate the actual creation to the method of the base class.

It is tempting to summarize this process by saying:

"__new__ allocates memory."

But this formulation mixes different levels.

__new__ is the instance creation hook exposed by Python's data model. The low-level work necessary to produce the object, including allocation details, belongs to the runtime and the implementation used. Therefore, it is more precise to say that __new__ controls the creation and the object that will be returned, rather than treating it as synonymous with the memory allocator.

There is another important rule.

__init__ is only called automatically if __new__ returns an instance of the requested class — or of a subclass of it (PYTHON SOFTWARE FOUNDATION, 2026c).

For example:

class Factory:
    def __new__(cls) -> str:
        return "already exists"

    def __init__(self) -> None:
        print("this line will not be executed")


result = Factory()

result
# 'already exists'

type(result)
# <class 'str'>

Enter fullscreen mode Exit fullscreen mode

Since __new__ returned a str, not an instance of Factory, Factory's __init__ is not executed.

__new__ gains special importance when creating subclasses of immutable types, such as int, str, and tuple. Since the immutable object cannot have its value changed after creation, changes that affect its value often need to happen during the instance's own creation (PYTHON SOFTWARE FOUNDATION, 2026c).

For ordinary classes, however, __init__ remains the customization point necessary in the great majority of cases.

2.5. Finalization: __del__ Is Not a Resource Manager

At the other end of the lifecycle there is:

__del__
Enter fullscreen mode Exit fullscreen mode

The documentation calls it a finalizer. The term "destructor" is used informally, but can lead to an incorrect mental model (PYTHON SOFTWARE FOUNDATION, 2026c).

To understand the problem, we need to separate some concepts.

An object can cease to be reachable by the program:

object
↓
no path accessible from the program reaches it
↓
object has become unreachable
Enter fullscreen mode Exit fullscreen mode

This does not mean, as a general language rule:

unreachable = destroyed immediately
Enter fullscreen mode Exit fullscreen mode

Python allows an implementation to defer garbage collection or even omit it in certain situations. What the language guarantees is that objects still reachable are not collected (PYTHON SOFTWARE FOUNDATION, 2026a).

In CPython specifically, there is a scheme based primarily on reference counting, supplemented by a garbage collector capable of detecting cycles. For this reason, many objects are freed quickly when they no longer have references. This behavior, however, is a CPython detail, not a universal Python contract (PYTHON SOFTWARE FOUNDATION, 2026a).

__del__ also has other difficulties:

  • it may be executed during interpreter shutdown;
  • other global objects on which it depends may already be unavailable;
  • exceptions raised within __del__ are not propagated normally;
  • its execution may occur in delicate circumstances, making blocking operations — such as acquiring a lock — particularly dangerous (PYTHON SOFTWARE FOUNDATION, 2026c).

This makes __del__ a poor choice to be the sole responsible party for the release of important external resources.

Examples:

  • files;
  • sockets;
  • locks;
  • database connections;
  • transactions;
  • handles provided by the operating system.

These resources typically need a deterministic moment of release.

This is precisely where with comes in. For example:

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

The block has a well-defined point of entry and exit.

Conceptually:

with resource:
      ↓
__enter__()
      ↓
execute the block
      ↓
__exit__()
Enter fullscreen mode Exit fullscreen mode

Even when an exception occurs within the block, the context manager protocol provides an appropriate point to execute exit logic (PYTHON SOFTWARE FOUNDATION, 2026d).

This difference is fundamental:

__del__
→ finalization tied to the object's lifecycle

with / context manager
→ explicit management of a resource's lifecycle
Enter fullscreen mode Exit fullscreen mode

For resources that need to be released at a predictable moment, prefer context managers or explicit APIs, do not rely exclusively on __del__.

2.6. Arithmetic: NotImplemented and Reflected Operations

Special methods also allow objects to participate in operators.

Some examples:

Operation Method
a + b __add__
a - b __sub__
a * b __mul__
a / b __truediv__
a ** b __pow__
-a __neg__
abs(a) __abs__

But implementing operators correctly requires more than simply writing the calculation.

Consider a duration stored in minutes:

from __future__ import annotations

from dataclasses import dataclass
from types import NotImplementedType


@dataclass(frozen=True)
class Duration:
    minutes: int

    def __add__(self, other: object) -> Duration | NotImplementedType:
        if not isinstance(other, Duration):
            return NotImplemented

        return Duration(self.minutes + other.minutes)

    def __mul__(self, factor: object) -> Duration | NotImplementedType:
        if not isinstance(factor, int):
            return NotImplemented

        return Duration(self.minutes * factor)

    __rmul__ = __mul__

    def __str__(self) -> str:
        hours, minutes = divmod(self.minutes, 60)
        return f"{hours}h{minutes:02d}"
Enter fullscreen mode Exit fullscreen mode

Now:

Duration(90) + Duration(45)
# Duration(minutes=135)

3 * Duration(50)
# Duration(minutes=150)

print(Duration(135))
# 2h15
Enter fullscreen mode Exit fullscreen mode

But:

Duration(90) + 45
# TypeError
Enter fullscreen mode Exit fullscreen mode

The central point is in this line:

return NotImplemented
Enter fullscreen mode Exit fullscreen mode

NotImplemented is a special singleton object used by numeric methods and comparisons when that method does not implement the operation for the pair of operands received (PYTHON SOFTWARE FOUNDATION, 2026e).

This is different from saying:

"Never raise an exception inside an operator."

This rule would be too broad.

If the problem is:

"My __add__ does not know how to add Duration with this type of object."

then NotImplemented is normally the appropriate response.

If the operation is supported, but a received value violates some legitimate domain rule, an appropriate exception can still make sense.

And Why Does __rmul__ Exist?

Consider:

3 * Duration(50)
Enter fullscreen mode Exit fullscreen mode

In this specific case, int is on the left.

For this pair of types:

int.__mul__(3, Duration(50))
Enter fullscreen mode Exit fullscreen mode

does not know how to produce a result and returns:

NotImplemented
Enter fullscreen mode Exit fullscreen mode

This allows Python to try the reflected form of the operation on the other operand:

Duration.__rmul__(Duration(50), 3)
Enter fullscreen mode Exit fullscreen mode

Our method accepts the integer factor and returns:

Duration(150)
Enter fullscreen mode Exit fullscreen mode

A simplified model is:

a * b
  ↓
appropriate method supports the operation?
  ↓ no
NotImplemented
  ↓
reflected attempt
  ↓
supports?
  ├─ yes → result
  └─ no → TypeError
Enter fullscreen mode Exit fullscreen mode

There is an important nuance: this is not always a rigid rule of "left side first, right side second".

When operands have different types and the type on the right is a subclass of the type on the left, the reflected method of the subclass may receive priority. This allows the more specific implementation to have the opportunity to control the result (PYTHON SOFTWARE FOUNDATION, 2026f).

For int and Duration, however, there is no such inheritance relationship, and the flow described above correctly represents:

3 * Duration(50)
Enter fullscreen mode Exit fullscreen mode

In this example:

__rmul__ = __mul__
Enter fullscreen mode Exit fullscreen mode

is valid because multiplying a duration by a scalar has the same logic regardless of the syntactic order.

This should not be copied automatically to any operator: operations like subtraction and division are not commutative.

NotImplemented is NOT NotImplementedError

NotImplemented is a returned value by protocols like numeric operations and comparisons to indicate that this combination of operands is not supported.

NotImplementedError is an exception, normally used when an API or implementation deliberately leaves a particular operation unimplemented.

The official documentation makes explicit that the two are not interchangeable (PYTHON SOFTWARE FOUNDATION, 2026g).

2.7. Attributes: __getattr__ and __setattr__

The access:

obj.name
Enter fullscreen mode Exit fullscreen mode

also goes through the data model.

Before reaching __getattr__, Python performs the normal attribute lookup mechanism, which involves the instance, its class, the class hierarchy, and, when applicable, descriptors.

Only when this lookup ends in AttributeError does __getattr__ function as a fallback (PYTHON SOFTWARE FOUNDATION, 2026h).

Conceptually:

obj.name
   ↓
normal attribute lookup
   ↓
found?
├─ yes → returns the value
└─ no
    ↓
__getattr__(obj, "name")
Enter fullscreen mode Exit fullscreen mode

This is important because __getattr__ does not participate in every attribute read.

There is another method:

__getattribute__
Enter fullscreen mode Exit fullscreen mode

that participates unconditionally in the access to instance attributes and allows controlling the process much more broadly.

We will not delve into it here, but the distinction is important:

__getattribute__
→ participates in every normal read

__getattr__
→ fallback when lookup fails
Enter fullscreen mode Exit fullscreen mode

Now we can build a read-only configuration:

class Config:
    _data: dict[str, str]

    def __init__(self, **values: str) -> None:
        object.__setattr__(self, "_data", values)

    def __getattr__(self, name: str) -> str:
        try:
            return self._data[name]
        except KeyError:
            raise AttributeError(f"'{name}' does not exist in config") from None

    def __setattr__(self, name: str, value: str) -> None:
        raise AttributeError("Config is read-only")
Enter fullscreen mode Exit fullscreen mode

Usage:

cfg = Config(
    host="localhost",
    port="5432",
)

cfg.host
# 'localhost'

cfg.port
# '5432'
Enter fullscreen mode Exit fullscreen mode

host and port do not exist as normal attributes of the instance.

When:

cfg.host
Enter fullscreen mode Exit fullscreen mode

is not found by normal lookup, __getattr__ receives:

name == "host"
Enter fullscreen mode Exit fullscreen mode

and looks up the information in:

self._data
Enter fullscreen mode Exit fullscreen mode

Why Does _data Start With an Underscore?

A single leading underscore is a internal use convention used to communicate:

"This name is part of the internal implementation and should not be treated as public API."

It does not create true access control.

It is still possible to write:

cfg._data
Enter fullscreen mode Exit fullscreen mode

Python does not block this access.

This is different from:

__data
Enter fullscreen mode Exit fullscreen mode

Two leading underscores activate a mechanism called name mangling, which transforms the name internally to reduce accidental collisions, mainly in inheritance scenarios.

For our example, _data communicates exactly what we want: it is a non-public implementation detail.

And Why Do We Use object.__setattr__?

Now observe:

def __setattr__(self, name: str, value: str) -> None:
    raise AttributeError("Config is read-only")
Enter fullscreen mode Exit fullscreen mode

__setattr__ is called when we attempt to perform an attribute assignment (PYTHON SOFTWARE FOUNDATION, 2026h).

Therefore:

cfg.host = "other"
Enter fullscreen mode Exit fullscreen mode

results in:

AttributeError: Config is read-only
Enter fullscreen mode Exit fullscreen mode

The same would happen inside __init__ itself if we wrote:

self._data = values
Enter fullscreen mode Exit fullscreen mode

This instruction is also an attribute assignment:

self._data = values
        ↓
self.__setattr__("_data", values)
        ↓
AttributeError
Enter fullscreen mode Exit fullscreen mode

For this reason we do:

object.__setattr__(self, "_data", values)
Enter fullscreen mode Exit fullscreen mode

We are not "turning off" the entire attribute model. We are calling directly the implementation of the base class and, with that, avoiding our custom Config.__setattr__.

The documentation itself recommends this pattern when a __setattr__ needs to perform an actual assignment on the instance (PYTHON SOFTWARE FOUNDATION, 2026h).

Recursion Pitfall

An implementation like:

def __setattr__(self, name, value):
    self.name = value
Enter fullscreen mode Exit fullscreen mode

would call __setattr__ again, which would execute another assignment, which would call __setattr__ again...

When it is necessary to delegate to the base behavior, explicitly use object.__setattr__.

Why Transform KeyError Into AttributeError?

Inside __getattr__ we have:

try:
    return self._data[name]
except KeyError:
    raise AttributeError(f"'{name}' does not exist in config") from None
Enter fullscreen mode Exit fullscreen mode

The dictionary expresses absence through:

KeyError
Enter fullscreen mode Exit fullscreen mode

But we are implementing attribute access.

In this protocol, absence must be communicated through:

AttributeError
Enter fullscreen mode Exit fullscreen mode

This is important because other Python mechanisms also depend on this exception to determine if an attribute exists. from None suppresses the automatic display of the context with the previous KeyError in the traceback, leaving visible only the exception that correctly represents the class's public abstraction (PYTHON SOFTWARE FOUNDATION, 2026j). Without from None, Python would also show the context of the previous exception.

The Next Layer: Descriptors

Up to this point we have customized operations directly on the instance.

Descriptors solve another problem: they allow an attribute stored on the class to control what happens when it is read, written, or removed.

The protocol uses:

__get__
__set__
__delete__
Enter fullscreen mode Exit fullscreen mode

Conceptually:

obj.attribute
    ↓
Python finds a descriptor object on the class
    ↓
__get__(...)
Enter fullscreen mode Exit fullscreen mode

or, during write:

obj.attribute = value
        ↓
__set__(...)
Enter fullscreen mode Exit fullscreen mode

This mechanism is behind fundamental language features, including property, bound methods, and classmethod (PYTHON SOFTWARE FOUNDATION, 2026k).

For example:

class Person:
    def __init__(self, name: str) -> None:
        self._name = name

    @property
    def name(self) -> str:
        return self._name
Enter fullscreen mode Exit fullscreen mode

The object created by property participates in the descriptors protocol.

We do not need to delve into the entire mechanics now. The important point is to realize that attribute access is also extensible through data model protocols.

2.8. Protocols Are More Than Method Names

We can now refine the definition.

A protocol does not merely mean:

"There is a method with a particular name."

It also involves the semantic contract expected of that method.

For example:

  • __len__ should represent size and return a non-negative integer;
  • __getitem__ of a sequence should use appropriate exceptions for invalid indices;
  • an iterator should signal its end through StopIteration;
  • __eq__ can return NotImplemented for operands it does not know how to compare;
  • __hash__ must remain coherent with equality.

It is possible to write a method with the correct name and still implement the protocol poorly.

For this reason, we are going to start by applying this principle to a single class, which will continue evolving in the next part of the series.

Our base will be an N-dimensional vector:

class Vector:
    def __init__(self, *components: float) -> None:
        self._components = list(components)
Enter fullscreen mode Exit fullscreen mode

Now we can create:

Vector(3, 4)
Vector(1, 2, 3)
Vector(2, 5, 8, 13)
Enter fullscreen mode Exit fullscreen mode

The components are stored in an internal collection:

_components
Enter fullscreen mode Exit fullscreen mode

The Vector in this article will have two important design decisions:

  1. The values of the components can be changed;
  2. The number of components will remain fixed after construction.

Therefore, it will be mutable as to values, but will not offer operations that add or remove dimensions.

For didactic purposes we will also allow:

Vector()
Enter fullscreen mode Exit fullscreen mode

representing a vector without components. A real domain might reject this construction if it did not make sense.

From here on, each new method must respect these same decisions. In this part, we will start with textual representation; in the next, we will continue evolving the same Vector through the protocols of size, indexing, iteration, and comparison.


3. Representation: __repr__ and __str__

Let us start with the most visible behavior.

Without customized __repr__ or __str__:

v = Vector(3, 4)

v
Enter fullscreen mode Exit fullscreen mode

in CPython normally produces something like:

<__main__.Vector object at 0x...>
Enter fullscreen mode Exit fullscreen mode

The exact format of this default representation should not be treated as part of our class's contract.

What we want to answer is:

How should a Vector represent itself textually?

Python offers two main methods for this.

Aspect __repr__ __str__
Role in documentation official representation informal or easily printable representation
Common purpose rich in information and unambiguous convenient and readable
Typical use REPL, debugging, technical logs presentation for human reading
Triggered by repr(v), f"{v!r}" str(v), print(v), f"{v}", f"{v!s}"

The "developer versus end user" distinction can be a useful analogy, but it is not the normative definition.

The documentation defines __repr__ as responsible for the "official" representation and recommends, when possible, a form resembling a Python expression capable of recreating an equivalent object. __str__, on the other hand, can produce a more convenient or concise representation (PYTHON SOFTWARE FOUNDATION, 2026c).

Let us start with __repr__:

class Vector:
    # ... __init__ from the previous section

    def __repr__(self) -> str:
        components = ", ".join(
            repr(component)
            for component in self._components
        )

        return f"Vector({components})"
Enter fullscreen mode Exit fullscreen mode

Now:

v = Vector(3, 4)

repr(v)
# 'Vector(3, 4)'

v
# Vector(3, 4)
Enter fullscreen mode Exit fullscreen mode

And for different dimensions:

Vector(1, 2, 3)
# Vector(1, 2, 3)

Vector()
# Vector()
Enter fullscreen mode Exit fullscreen mode

The representation accompanies the data actually stored. There is no longer any hardcoded assumption that a vector must have only x and y.

Now we add a more compact form for __str__:

class Vector:
    # ...

    def __str__(self) -> str:
        components = ", ".join(
            str(component)
            for component in self._components
        )

        return f"({components})"
Enter fullscreen mode Exit fullscreen mode

With that:

v = Vector(3, 4)

repr(v)
# 'Vector(3, 4)'

str(v)
# '(3, 4)'

print(v)
# (3, 4)

f"{v}"
# '(3, 4)'

f"{v!s}"
# '(3, 4)'

f"{v!r}"
# 'Vector(3, 4)'
Enter fullscreen mode Exit fullscreen mode

The Fallback of __str__

There is still an important relationship between the two methods.

If we define __repr__, but not __str__, the official representation is also used when an informal representation is needed (PYTHON SOFTWARE FOUNDATION, 2026c).

For this reason, a class with only:

def __repr__(self) -> str:
    return "..."
Enter fullscreen mode Exit fullscreen mode

already improves both:

repr(obj)
Enter fullscreen mode Exit fullscreen mode

and:

str(obj)
print(obj)
Enter fullscreen mode Exit fullscreen mode

The opposite does not have the same relationship: implementing only __str__ does not substitute for the need for an appropriate official representation.

And Containers?

Observe:

v = Vector(3, 4)

[v]
# [Vector(3, 4)]
Enter fullscreen mode Exit fullscreen mode

Even though:

str(v)
Enter fullscreen mode Exit fullscreen mode

produces:

(3, 4)
Enter fullscreen mode Exit fullscreen mode

a list's representation uses the official representation of its elements.

This is useful for debugging:

vectors = [
    Vector(1, 2),
    Vector(3, 4),
]

vectors
# [Vector(1, 2), Vector(3, 4)]
Enter fullscreen mode Exit fullscreen mode

The container can clearly show which objects it contains, without depending on the more informal version produced by __str__.


4. Conclusion

Throughout this part, we moved beyond the idea that methods like __len__, __repr__, or __add__ are simply special names that Python calls automatically. The broader point is that they are points of integration between our types and the language's protocols.

When we write:

len(obj)
Enter fullscreen mode Exit fullscreen mode

or:

a * b
Enter fullscreen mode Exit fullscreen mode

or even:

obj.name
Enter fullscreen mode Exit fullscreen mode

we are not merely calling isolated functions or operators. We are triggering behaviors defined by the data model, each with its own resolution rules and its own contracts.

For this reason, throughout the article, we saw that:

  • len(obj) is not equivalent to an ordinary call to obj.__len__();
  • __new__ and __init__ participate in different moments of instance creation;
  • __del__ should not be confused with deterministic resource management;
  • NotImplemented is part of the negotiation between operands;
  • __getattr__ functions as a fallback in attribute access;
  • descriptors form another protocol behind mechanisms like property;
  • __repr__ and __str__ represent different contracts for the textual representation of an object.

These examples also show why implementing a dunder method does not merely mean using the correct name. To properly participate in a protocol, the class must respect the expected semantics of that operation.

The Vector introduced at the end of this part is our point of continuity.

So far, we have defined how it stores its components and how it should represent itself:

Vector(3, 4)
Enter fullscreen mode Exit fullscreen mode

or, in its informal form:

(3, 4)
Enter fullscreen mode Exit fullscreen mode

In the next part, the question changes.

Instead of observing protocols in isolation, we will see what happens when several of them need to coexist in the same type. We will decide, among other things:

len(v)
Enter fullscreen mode Exit fullscreen mode

should represent what?

How should:

v[0]
v[-1]
v[1:3]
Enter fullscreen mode Exit fullscreen mode

behave?

What makes:

for component in v:
    ...
Enter fullscreen mode Exit fullscreen mode

possible?

And if the Vector is mutable, how does that affect:

v1 == v2
Enter fullscreen mode Exit fullscreen mode

and its ability to be used in a set or as a dict key?

It is at this point that protocols cease to seem like independent features and begin to reveal something even more important about the data model: behavioral decisions in one part of the class can impose consequences on several others.

In Part 3, we will continue precisely from there, evolving the same Vector through the protocols of size, indexing, iteration, equality, and hashing.


References

PYTHON SOFTWARE FOUNDATION. 3.1. Objects, values and types. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026a. Available at: https://docs.python.org/3.14/reference/datamodel.html#objects-values-and-types. Accessed on: Aug. 14, 2026.

PYTHON SOFTWARE FOUNDATION. 3.3.13. Special method lookup. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026b. Available at: https://docs.python.org/3.14/reference/datamodel.html#special-method-lookup. Accessed on: Aug. 14, 2026.

PYTHON SOFTWARE FOUNDATION. 3.3.1. Basic customization. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026c. Available at: https://docs.python.org/3.14/reference/datamodel.html#basic-customization. Accessed on: Aug. 14, 2026.

PYTHON SOFTWARE FOUNDATION. 3.3.9. With Statement Context Managers. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026d. Available at: https://docs.python.org/3.14/reference/datamodel.html#with-statement-context-managers. Accessed on: Aug. 14, 2026.

PYTHON SOFTWARE FOUNDATION. 3.2.2. NotImplemented. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026e. Available at: https://docs.python.org/3.14/reference/datamodel.html#notimplemented. Accessed on: Aug. 14, 2026.

PYTHON SOFTWARE FOUNDATION. 3.3.8. Emulating numeric types. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026f. Available at: https://docs.python.org/3.14/reference/datamodel.html#emulating-numeric-types. Accessed on: Aug. 14, 2026.

PYTHON SOFTWARE FOUNDATION. NotImplementedError. In: Built-in Exceptions — Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026g. Available at: https://docs.python.org/3.14/library/exceptions.html#NotImplementedError. Accessed on: Aug. 14, 2026.

PYTHON SOFTWARE FOUNDATION. 3.3.2. Customizing attribute access. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026h. Available at: https://docs.python.org/3.14/reference/datamodel.html#customizing-attribute-access. Accessed on: Aug. 14, 2026.

PYTHON SOFTWARE FOUNDATION. 9.6. Private Variables. In: The Python Tutorial — Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026i. Available at: https://docs.python.org/3.14/tutorial/classes.html#private-variables. Accessed on: Aug. 14, 2026.

PYTHON SOFTWARE FOUNDATION. 7.8. The raise statement. In: Python Language Reference — Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026j. Available at: https://docs.python.org/3.14/reference/simple_stmts.html#the-raise-statement. Accessed on: Aug. 14, 2026.

PYTHON SOFTWARE FOUNDATION. Descriptor Guide — Descriptor protocol. In: Python 3.14.7 documentation. [S. l.]: Python Software Foundation, 2026k. Available at: https://docs.python.org/3.14/howto/descriptor.html#descriptor-protocol. Accessed on: Aug. 14, 2026.

Editorial note: This article was developed and reviewed by the author with AI assistance during research, technical auditing, and editorial review. Technical claims were verified against the official sources listed in the references.

Top comments (0)