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.
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.
Summary
- Every attribute access in Python (
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. - Insight: Once you see attribute lookup and dunder dispatch as the two shared mechanisms, inheritance, properties, and operator overloading stop being independent topics and become predictable applications of the same rules.
- Key Concepts: class as object, instance
__dict__, method resolution order (MRO), descriptor protocol, dunder method dispatch. - When to Use: Debugging why an attribute resolves to an unexpected value, deciding between inheritance and composition, or understanding why
@propertyand bound methods behave the way they do. - Limitations/Trade-offs: The flexibility of dynamic attribute lookup and operator overloading costs some predictability - two objects that look similar can behave very differently depending on which dunder methods they implement.
- Related Topics: metaclasses, structural typing, the descriptor protocol, composition versus inheritance.
Foundations
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.
Mechanics & Interactions
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.
Advanced Considerations & Applications
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.
Common Misconceptions
- "Classes are just blueprints, not real objects." A class is a first-class object (an instance of
type), which is why you can pass a class around, store it in a variable, or attach attributes to it directly. - "Every method call looks up the function fresh on the instance." Methods live on the class, not the instance; accessing them through an instance triggers the descriptor protocol to produce a bound method, it doesn't copy the function anywhere.
- "
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_checkableProtocols,isinstance()checks can pass purely because an object happens to have the right method names, with no shared base class at all. - "
@dataclassis 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.
FAQs
Is a Python class actually an object itself?
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.
What is the exact order Python uses to look up `instance.name`?
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.
What is a descriptor, in plain terms?
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.
Why does `@property` beat an instance's own `__dict__` entry with the same name?
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.
How do dunder methods let my class use `+`, `len()`, or `for`?
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.
Does `super()` always call the parent class I inherited from directly?
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."
How is `typing.Protocol` different from inheriting a base class?
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.
Does `@dataclass` replace normal class mechanics?
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.
What does a metaclass actually let you do?
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.
Why do deep inheritance hierarchies tend to become fragile?
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 should I reach for composition instead of inheritance?
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.
Related
- OOP Basics - hands-on classes,
self, and instance attributes - Dunder / Magic Methods - the specific hooks this page's dispatch model relies on
- Inheritance & MRO - the method resolution order in full detail
- Properties & Descriptors - the descriptor protocol this page introduces
- Protocols & Structural Typing - structural checks against this same lookup model
- dataclasses - generated dunder methods on ordinary classes
Stack 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.