The Python Object Model
Classes, dunder methods, inheritance, dataclasses, and protocols all look like separate features, but they're really different entry points into one object model.
Search across all documentation pages
Classes, dunder methods, inheritance, dataclasses, and protocols all look like separate features, but they're really different entry points into one object model.
That model has two moving parts: how Python looks up an attribute on an object, and how special dunder methods let ordinary syntax (+, len(), for, with) operate on objects you define.
This page explains both, so that Dunder / Magic Methods, Inheritance & MRO, Properties & Descriptors, and Protocols & Structural Typing read as variations on a theme rather than a pile of unrelated syntax to memorize.
obj.name) follows one lookup algorithm - instance dict first, then the class's method resolution order - and dunder methods are how objects hook into built-in syntax.__dict__, method resolution order (MRO), descriptor protocol, dunder method dispatch.@property and bound methods behave the way they do.In Python, a class is itself an object - specifically, an instance of type (or a custom metaclass) - which is why you can attach attributes directly to a class body, and why type(MyClass) returns type while type(instance) returns MyClass.
Creating an instance (MyClass()) builds a new object with its own __dict__ (its personal namespace for instance attributes), plus a hidden link back to the class that created it.
Accessing instance.name triggers a lookup: Python first checks the instance's own __dict__, and if the name isn't there, it walks the class's method resolution order (MRO) - the class itself, then its bases, in a well-defined order - looking for the name in each class's __dict__.
A simple analogy: the instance dict is a sticky note pad taped to one specific object, while the class (and its bases) is a shared reference manual everyone of that type consults when their own sticky notes don't have the answer.
Methods work through this same lookup: def greet(self) inside a class body is just a function object stored in the class's __dict__, and accessing it through an instance is what turns it into a "bound method" that automatically supplies self.
The mechanism that turns a plain function into a bound method - and that powers @property - is the descriptor protocol: any object with __get__ (and optionally __set__/__delete__) defined on its class can intercept attribute access instead of being returned as-is.
Descriptors that define __set__ are called data descriptors, and they take priority over an instance's own __dict__ entry of the same name - which is the exact reason @property can enforce validation on every assignment even though the instance technically "has" that attribute.
class Celsius:
def __get__(self, obj, owner):
return obj._celsius
def __set__(self, obj, value):
if value < -273.15:
raise ValueError("below absolute zero")
obj._celsius = value
class Weather:
temperature = Celsius() # a data descriptor beats instance __dict__ lookupDunder methods are the second half of the model: Python's syntax never special-cases your classes directly - len(obj) calls obj.__len__(), obj + other calls obj.__add__(other), for x in obj calls obj.__iter__(), and with obj: calls obj.__enter__()/obj.__exit__().
This is why implementing a handful of dunder methods lets a custom class participate fully in Python's syntax - the language was designed around a small, consistent hook system rather than a large set of built-in special cases.
super() and the MRO interact with this same lookup: super().method() doesn't call "the parent class" naively, it calls the next class after the current one in the MRO, which is what makes cooperative multiple inheritance (mixins) work correctly instead of skipping or double-calling shared bases.
dataclasses and typing.Protocol look like alternatives to "real" OOP, but both operate entirely within this same model rather than replacing it: @dataclass is a class decorator that generates __init__, __repr__, and __eq__ dunder methods for you at class-creation time, saving the boilerplate of writing them by hand.
typing.Protocol flips the direction of the same lookup model: instead of requiring an object's MRO to include a specific base class, a Protocol checks (statically, or at runtime with @runtime_checkable) whether an object's class happens to define the right methods - the same attribute lookup mechanism, just checked structurally instead of by inheritance chain.
Metaclasses (type subclasses, or __init_subclass__ as the lighter-weight alternative) sit one level up: since classes are themselves objects created by type, a custom metaclass can intercept class creation itself, which is how frameworks like ORMs auto-register model classes or validate class bodies at definition time.
| Approach | Strength | Weakness | Best fit |
|---|---|---|---|
| Inheritance (base class) | Shares implementation and enforces a real "is-a" relationship | Deep hierarchies get rigid and hard to change safely | A genuine specialization relationship with shared behavior |
| Composition (has-a) | Flexible, swappable at runtime, avoids fragile hierarchies | More boilerplate to delegate calls explicitly | Combining independent behaviors without forcing a type hierarchy |
typing.Protocol (structural) | No inheritance required; works with third-party types you can't modify | Static-checker-oriented; runtime checks only cover method existence, not behavior | Defining an interface for code you don't own, or minimal coupling |
@dataclass | Generates boilerplate dunder methods automatically | Still a regular class underneath - doesn't replace inheritance decisions | Data-centric records without hand-written __init__/__eq__ |
The practical failure mode this model explains is fragile base class problems: because attribute lookup walks a live MRO at call time rather than binding statically, changing a base class's method can silently change behavior in every subclass that relied on the old version, which is a core argument for favoring composition or small, focused mixins over deep inheritance trees.
type), which is why you can pass a class around, store it in a variable, or attach attributes to it directly.super().method() calls the immediate parent class." It calls the next class in the MRO after the current one, which for multiple inheritance is not always the same thing as "the parent" in a simple sense.isinstance() always means real inheritance." With @runtime_checkable Protocols, isinstance() checks can pass purely because an object happens to have the right method names, with no shared base class at all.@dataclass is a different kind of class." It's an ordinary class with generated dunder methods; everything about attribute lookup and MRO described here still applies to it unchanged.Yes - a class is an instance of type (or a custom metaclass), which is why classes can carry their own attributes and be passed around like any other value.
It checks the instance's own __dict__ first, then walks the class's method resolution order (the class itself, then its bases in MRO order), searching each one's __dict__ in turn - unless a data descriptor intercepts the lookup first.
An object whose class defines __get__ (and optionally __set__/__delete__) so that accessing it as a class attribute runs that code instead of returning it directly - it's the mechanism behind bound methods, @property, and @staticmethod.
Because property is a data descriptor (it defines __set__), and data descriptors take priority over instance __dict__ entries in the attribute lookup order, which is exactly what lets a property validate or transform values on every assignment.
Python's syntax doesn't special-case built-in types - a + b calls a.__add__(b), len(obj) calls obj.__len__(), and for x in obj calls obj.__iter__() - so implementing the right dunder methods makes any custom class participate in that syntax.
No - it calls the next class after the current one in the MRO, which in multiple inheritance can be a sibling mixin rather than what you'd informally call "the parent."
A base class requires an explicit "is-a" relationship declared in the class definition; a Protocol instead checks structurally whether an object's class happens to define the right method names, with no inheritance relationship required at all.
No - it's a decorator that generates common dunder methods (__init__, __repr__, __eq__, and more) at class-creation time; the resulting class still follows the exact same attribute lookup and MRO rules as any other class.
Since classes are themselves objects created by type, a custom metaclass can intercept class creation - validating a class body, auto-registering the class, or injecting attributes - before any instance of it ever exists.
Because attribute lookup walks a live MRO at call time rather than resolving statically, changing a method on a base class can silently change behavior in every subclass depending on it, which grows harder to reason about as the hierarchy deepens.
When you want to combine independent behaviors without committing to a rigid "is-a" hierarchy - composition delegates explicitly to other objects, which is more verbose but far easier to change safely than a deep or multiple-inheritance chain.
self, and instance attributesStack versions: This page was written for Python 3.14 (stable) and Python 3.13 (maintenance); the descriptor protocol, MRO, and dunder dispatch described here have been stable CPython behavior across recent 3.x releases.
Reviewed by Chris St. John·Last updated Jul 15, 2026