What MRO decides
When you access obj.attr and multiple base classes could define attr,
the MRO is the order Python searches classes to find it. For single
inheritance it's obvious (child, then parent, then grandparent...); with
multiple inheritance, it's not — and Python needs a deterministic rule.
class A:
def who(self):
return "A"
class B(A):
def who(self):
return "B"
class C(A):
def who(self):
return "C"
class D(B, C):
pass
D().who() # 'B'
D.__mro__
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
Naive depth-first search (D → B → A → C → A) would visit A twice and
find B's who before C's only by luck of ordering. C3 linearization
guarantees a single, consistent order: D, B, C, A, object.
C3 linearization, in plain terms
C3 merges the MROs of each parent with the list of parents itself, subject to two rules:
- Local precedence order — a class appears before its own parents.
- Monotonicity — if a class appears before another in a parent's MRO, it must appear before it in the child's MRO too.
The algorithm repeatedly takes the head of the first list that doesn't
appear in the tail of any other list — this is what guarantees D's bases
(B then C, in the order declared) are respected while still visiting
each ancestor exactly once.
Why this makes cooperative super() calls safe
Because the MRO is a single linear order shared by every class in the
hierarchy, calling super().method() inside B doesn't go straight to
A — it goes to whatever is next in D's MRO after B, which is C.
This is what makes "cooperative multiple inheritance" (mixins) work: each
class's super() call passes control along the same MRO, so every class
in the chain gets a turn, in a consistent order, regardless of which
concrete subclass you instantiate.
Inconsistent hierarchies raise an error
class X(A, B): pass
class Y(B, A): pass
class Z(X, Y): pass # TypeError: Cannot create a consistent MRO
If two base classes disagree on relative ordering, C3 has no valid linearization and Python refuses to create the class rather than guessing.
Interview-ready summary: MRO is the deterministic search order for
methods/attributes across a (possibly multiple-inheritance) class
hierarchy, computed by C3 linearization and inspectable via __mro__. It
guarantees each ancestor is visited once, respects declared base-class
order, and is what makes super() reliably cooperative rather than
hard-coded to "my direct parent."
Related Resources
super() is "next in MRO," not "my parent"
class LoggingMixin:
def __init__(self, *a, **kw):
print("LoggingMixin init")
super().__init__(*a, **kw)
class TimestampMixin:
def __init__(self, *a, **kw):
print("TimestampMixin init")
super().__init__(*a, **kw)
class Service(LoggingMixin, TimestampMixin):
def __init__(self):
print("Service init")
super().__init__()
Service()
# Service init
# LoggingMixin init
# TimestampMixin init
Service.__mro__ is [Service, LoggingMixin, TimestampMixin, object].
Inside LoggingMixin.__init__, super() doesn't call object.__init__
directly — it calls whatever is next after LoggingMixin in Service's
MRO, which happens to be TimestampMixin. This only works because every
mixin cooperatively calls super().__init__() and accepts *args, **kwargs
it doesn't recognize, passing them along.
Why "cooperative" multiple inheritance requires this discipline
If any mixin in the chain doesn't call super().__init__(), the chain
breaks and classes later in the MRO never get initialized. This is why
well-designed mixins always call super() even though, read in isolation,
it's not obvious what "next" even refers to — it depends on the final
class that combines them, which the mixin author can't know in advance.
Single inheritance: looks simple, same mechanism
class Animal:
def __init__(self, name):
self.name = name
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name) # calls Animal.__init__
self.breed = breed
Here super() happens to resolve to Animal because the MRO is just
[Dog, Animal, object] — but it's the same "next in MRO" lookup, not
special-cased single-inheritance syntax.
super() vs calling the parent class explicitly
class Dog(Animal):
def __init__(self, name, breed):
Animal.__init__(self, name) # works, but bypasses MRO
self.breed = breed
Animal.__init__(self, name) calls Animal directly and skips the MRO
entirely — in multiple inheritance this can call a class twice or skip
classes, so super() is preferred whenever cooperative multiple
inheritance (mixins) is in play.
Interview-ready summary: super() doesn't mean "my parent class" — it
means "the next class after me in this instance's MRO." That indirection
is precisely what allows mixins to be composed in any order and still all
run, as long as every class in the chain forwards the call with
super().__init__(*args, **kwargs).
Related Resources
The three method types side by side
class Pizza:
def __init__(self, toppings):
self.toppings = toppings
def add_topping(self, topping): # instance method: needs a specific pizza
self.toppings.append(topping)
@classmethod
def margherita(cls): # classmethod: alternative constructor
return cls(["mozzarella", "tomato"])
@staticmethod
def is_valid_topping(name): # staticmethod: no self/cls needed
return name.lower() in KNOWN_TOPPINGS
add_toppingneedsselfbecause it mutates a specific pizza's toppings — calling it makes no sense without an instance.margheritais a factory method:Pizza.margherita()builds a new instance without the caller needing to know the constructor's exact argument list.clsrefers to the actual class it's called on, so a subclass callingmargherita()correctly builds that subclass, not hard-coded toPizza.is_valid_toppingdoesn't touch instance or class state at all — it's grouped insidePizzapurely for namespacing/discoverability (Pizza.is_valid_topping("basil")), and could just as well be a module-level function.
Why classmethod respects subclassing and staticmethod doesn't apply
class StuffedCrustPizza(Pizza):
pass
StuffedCrustPizza.margherita() # returns a StuffedCrustPizza, not a Pizza!
Because margherita uses cls(...) rather than Pizza(...), calling it
on a subclass constructs the subclass. This is the main reason to prefer
@classmethod factories over hardcoding the class name.
When to choose which
| Need | Choice |
|---|---|
| Operates on instance data | instance method |
| Alternative constructor / needs the actual class | @classmethod |
| Utility function that's logically related but touches no instance/class state | @staticmethod (or just a module function) |
Interview-ready summary: Instance methods operate on self;
classmethods operate on cls and are the standard pattern for alternative
constructors that respect subclassing; staticmethods are plain functions
namespaced under a class with no implicit self/cls access at all.
Related Resources
Basic usage
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("radius must be positive")
self._radius = value
@property
def area(self): # read-only, computed property
return 3.14159 * self._radius ** 2
c = Circle(5)
c.radius # 25 -> looks like an attribute, actually calls the getter
c.radius = 10 # calls the setter, validates
c.area # computed on the fly, no setter defined -> read-only
c.area = 100 # AttributeError: can't set attribute
Callers write c.radius, not c.get_radius() — the property mechanism is
invisible from the outside.
Why this beats Java/C#-style getters/setters written from day one
Python convention starts with a plain public attribute
(self.radius = radius, no property at all). If validation or computed
logic is needed later, converting it to a @property doesn't change the
public API — obj.radius still works exactly the same for every existing
caller. Writing get_radius()/set_radius() methods from the start (the
Java convention) locks callers into method-call syntax even when no
validation is needed, and later can't be un-done without breaking callers.
Read-only vs computed properties
A property with only a getter (like area above) is effectively
read-only — attempting c.area = 100 raises AttributeError. This is a
clean way to expose a derived value without letting callers set it
directly and risk it going out of sync with radius.
@property vs __slots__ and descriptors
property is itself implemented as a descriptor (it defines
__get__/__set__/__delete__) — it's the built-in, most common special
case of the more general descriptor protocol used for custom
attribute-access behavior (see also: descriptors question).
Interview-ready summary: @property lets an attribute-looking access
run arbitrary code (validation, computed values, logging) while keeping
the calling code's syntax unchanged. The idiomatic Python pattern is to
start with plain attributes and only introduce a property when you
actually need that behavior — never write boilerplate getters/setters
preemptively.
Related Resources
The descriptor protocol
A class is a descriptor if it implements any of:
class Descriptor:
def __get__(self, obj, objtype=None): ...
def __set__(self, obj, value): ...
def __delete__(self, obj): ...
When such an object is assigned as a class attribute of another class, accessing that attribute through an instance routes through the descriptor's methods instead of Python's normal attribute lookup.
A reusable validating descriptor
class PositiveNumber:
def __set_name__(self, owner, name):
self._name = "_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self._name)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self._name[1:]} must be positive")
setattr(obj, self._name, value)
class Circle:
radius = PositiveNumber() # reusable across classes/attributes
class Rectangle:
width = PositiveNumber()
height = PositiveNumber()
Unlike @property, which you write once per attribute, PositiveNumber
is written once and reused for radius, width, and height — this
is exactly how libraries like Django models and attrs/dataclasses
validators implement field-level behavior under the hood.
property is a descriptor
class property:
def __get__(self, obj, objtype=None): ...
def __set__(self, obj, value): ...
@property is a convenience wrapper that constructs exactly this kind of
descriptor object from the getter/setter/deleter functions you decorate —
it's the special case of "one descriptor instance per attribute, defined
inline," while a hand-written descriptor class is the general case of
"one reusable descriptor type, instantiated per attribute."
Data descriptors vs non-data descriptors
- Data descriptor: defines
__set__(and/or__delete__) — takes priority over instance__dict__even if the instance has its own entry with the same name. - Non-data descriptor: defines only
__get__— instance__dict__takes priority over it. This is how plain functions work as methods: a function is a non-data descriptor whose__get__produces a bound method, but you could shadow it by assigning directly intoinstance.__dict__["method_name"].
Interview-ready summary: Descriptors are the general mechanism behind
attribute access customization — property, bound methods, and
classmethod/staticmethod are all built on the descriptor protocol.
Write a custom descriptor when you need the same validation/behavior
reused across multiple attributes or classes; use @property for a
one-off attribute on a single class.