Classes: MRO, super(), descriptors & metaclasses
SeniorC3 linearization, what super() really is, __new__ vs __init__, type/object bootstrapping, the descriptor protocol and attribute lookup, property internals, and cooperative multiple inheritance.
Created 2 September 2026 · Updated 23 October 2026
21 concepts · detail hidden
01Method Resolution Order and C3 linearization, with diamond inheritance
#c-method-resolution-order-and-c3-linearization-with-diamond-inMRO (Method Resolution Order) is the specific, deterministic order Python searches through a class’s base classes (its “ancestors”) when looking for an attribute or method — it’s what decides which implementation actually gets called when multiple classes in a hierarchy define the same name. Python computes this using an algorithm called C3 linearization (a well-defined algorithm from the broader multiple-inheritance-language literature, not something Python invented, chosen specifically because it produces a consistent, predictable order even in complex multi-parent hierarchies, and detects/errors on contradictory hierarchies that have no valid linear order). Given:
class A: def f(self): return "A" class B(A): def f(self): return "B" class C(A): def f(self): return "C" class D(B, C): passThis is a diamond inheritance shape —
Dinherits from bothBandC, which both inherit fromA, forming a diamond if you draw the class relationships. C3 linearization produces the MROD → B → C → A → object(you can see this yourself viaD.__mro__) — the key intuition is that C3 respects two things simultaneously: each class must appear before its own parents (local precedence), and the order bases were listed in the subclass definition is respected (BbeforeC, sinceclass D(B, C)listedBfirst). SoD().f()returns"B"— Python findsfonBbefore it ever reachesCorAin the search order, even thoughCalso definesfand is “closer” toAin a naive tree-walk sense. C3 exists specifically to avoid the ambiguities and inconsistencies that naive depth-first or breadth-first searches produce in languages with multiple inheritance.02What `super()` actually returns — an MRO-following proxy, not the parent class
#c-what-super-actually-returns-an-mro-following-proxy-not-the-pIt returns a proxy object — a genuine
superinstance, bound both to a specific class and a specific object/type — that delegates any attribute lookup performed through it starting from the next class immediately after the given class in the MRO. Crucially, it is neither “the parent class” nor a materialized instance of it — it’s better understood as “wherever the MRO says to continue looking from this point onward.” This precise distinction is exactly whysuper()behaves genuinely differently fromBase.method(self)in a multi-inheritance diamond — the latter hardcodes a specific class, whilesuper()follows the computed MRO chain instead.04When the explicit two-argument `super(SomeClass, obj)` form is still needed
#c-when-the-explicit-two-argument-supersomeclass-obj-form-is-stReal, legitimate cases: needing to call a specific ancestor’s implementation that isn’t simply “the next one after my own class in
type(self).__mro__” — e.g., deliberately jumping to a specific point in a dynamically constructed or unusual MRO — or working within metaprogramming contexts manipulating class hierarchies at runtime, where the implicit__class__cell either isn’t correctly available or doesn’t refer to the class you actually intend (for instance, inside a function generated dynamically, not literally compiled inside the relevant class body, such as a method assembled by a decorator).05`super()` inside a `classmethod` vs a `staticmethod`
#c-super-inside-a-classmethod-vs-a-staticmethodsuper()works correctly inside aclassmethod— it resolves through the MRO exactly the same general way, just operating on class-level attribute lookup, correctly using the class itself (not any particular instance) as the binding object. Inside a plainstaticmethod, though, there simply is no implicitself/clsavailable at all to supply assuper()'s second argument — zero-argumentsuper()genuinely doesn’t have what it structurally needs there, meaning you’d have to fall back to the explicit two-argument form, manually passing a class yourself — a genuine, real edge case worth being aware of rather than assumingsuper()“just works everywhere inside a class body” unconditionally.06`__new__` vs `__init__` — and when to override `__new__`
#c-__new__-vs-__init__-and-when-to-override-__new____new__and__init__are two separate steps in object creation that are easy to conflate since you rarely need to think about them separately:__new__is a static method (called on the class itself, before any instance exists) responsible for actually creating and returning the new (usually still “empty”/unpopulated) instance;__init__then receives that already-created instance asselfand is responsible for initializing it — setting up its starting state/attributes. In the overwhelming majority of everyday Python code you never touch__new__at all, because the default implementation (inherited fromobject) does exactly what you want: allocate a blank instance and hand it to__init__. You’d override__new__specifically when you need to control something about instance creation itself, before__init__even runs — real cases: implementing a Singleton pattern (ensuring only one instance of a class is ever created, by having__new__check if an instance already exists and return the existing one instead of creating a new one — though a module-level instance is usually more idiomatic in Python, as discussed below); subclassing an immutable built-in type liketupleorstr(since immutable objects must have their final value fixed at creation time, before__init__would normally run, meaning any custom construction logic has to happen in__new__instead); or needing to conditionally return an instance of a different class entirely based on the arguments given (a pattern sometimes used for certain factory-like base classes).07`type` and `object` — the bootstrapping relationship, and why `type` is an instance of itself
#c-type-and-object-the-bootstrapping-relationship-and-why-type-typeis genuinely a class — specifically, the default metaclass (the “class of a class”) — so calling it,type(x), is really an act of class instantiation under the hood, even though from the outside it looks and behaves exactly like an ordinary function call.type(SomeOrdinaryClass)returnstypeitself (assuming no custom metaclass is involved);type(type)also returnstype— meaningtypeis, famously and a little dizzyingly, an instance of itself. Pushing one layer further:typeis a subclass ofobject, whileobjectis simultaneously an instance oftype— two objects that are each other’s ancestor and instance, in genuinely different senses, at the very same time. This is the classic bootstrapping oddity at the root of Python’s object model — a fun rabbit hole to go deeper into, though the high-level picture is also a perfectly reasonable place to stop.08Why `isinstance(x, type)` is asking "is `x` a class"
#c-why-isinstancex-type-is-asking-is-x-a-classisinstance(x, type)is effectively asking “isxitself a class” — that is, isx’s own type genuinelytype(or a subclass/custom metaclass of it). An ordinary instance’s type is nottypeitself —type(5)isint, andintitself is nottype— whereas an actual class’s type genuinely istype(or a metaclass, ultimately still descended from it). This connects back to the broader “everything in Python is an object” truism, while “everything is a class” is very much not equally true — most objects one deals with day to day are ordinary instances, not classes themselves.09The "metaclass conflict" error — cause and resolution
#c-the-metaclass-conflict-error-cause-and-resolutionThis error is triggered when a new class inherits from two base classes whose respective metaclasses aren’t related to each other by inheritance (neither is a subclass of the other) — Python genuinely can’t determine a single, consistent metaclass for the resulting class, since it needs to pick one metaclass that’s compatible (a non-strict subclass of) every base’s metaclass simultaneously, and no such single choice exists between two unrelated metaclasses. The resolution: define a brand-new metaclass that itself explicitly inherits from both of the conflicting metaclasses, then use that combined metaclass explicitly for the new class — resolving the ambiguity by hand rather than leaving it for Python to guess at. This is a genuinely common real-world situation to hit — a frequent trigger is mixing an ORM’s own custom metaclass-based base class with an unrelated ABC-based mixin.
10How `ABCMeta` and `@abstractmethod` block instantiation — a runtime check in metaclass `__call__`
#c-how-abcmeta-and-abstractmethod-block-instantiation-a-runtimeABCMetaachieves this entirely through ordinary metaclass mechanics — specifically, it overrides__call__on the metaclass itself, meaning it intercepts the act of instantiating a class (SomeClass()is, mechanically, a call toSomeClass’s metaclass) — at that exact moment, it checks whether the class being instantiated still has any methods marked with__isabstractmethod__ = True(the marker@abstractmethodapplies) that remain unimplemented by that class; if so, it raisesTypeErrorright there, before instantiation can proceed. It’s genuinely just a runtime check, dressed up to feel and behave like a compile-time contract enforcement, implemented entirely through ordinary metaclass-level method interception — no special language-level enforcement machinery beyond what any custom metaclass could, in principle, also implement.11Descriptors — data vs non-data, and how `property` is built on them
#c-descriptors-data-vs-non-data-and-how-property-is-built-on-thA descriptor is any object whose class defines one or more of
__get__,__set__, or__delete__— special “dunder” (double-underscore) methods that hook into attribute access itself. Placed on a class, a descriptor controls what happens when instances of that class read, assign to, or delete that attribute name.class Descriptor: def __get__(self, obj, objtype=None): print("__get__ called") return 42 def __set__(self, obj, value): print(f"__set__ called with {value!r}") class Thing: x = Descriptor() # the descriptor instance lives on the CLASS t = Thing() t.x # prints "__get__ called", evaluates to 42 t.x = 99 # prints "__set__ called with 99" — the assignment is interceptedWithout the descriptor,
t.x = 99would just drop99intot.__dict__. With it,__set__runs instead. That interception is the whole point, and it is the machinery behind@property,classmethod,staticmethod, bound methods, andfunctools.cached_property.Data vs. non-data descriptor
- A data descriptor defines
__set__and/or__delete__(usually plus__get__). - A non-data descriptor defines only
__get__.
The difference is who wins when the instance’s own
__dict__also has an entry for that name. The exact priorityobject.__getattribute__applies fort.name:- Look
nameup ontype(t).__mro__(class attributes only, first match wins). If the match is a data descriptor, call its__get__and return — unconditionally, even ift.__dict__also hasname. - Otherwise, if
nameis int.__dict__, return that value as-is. - Otherwise, if the class-level match from step 1 is a non-data descriptor, call its
__get__(this is how a function becomes a bound method); if it’s a plain class attribute, return it. - Otherwise raise
AttributeError— and only then is__getattr__called, if the class defines one.
So a data descriptor sits above the instance dict; a non-data descriptor sits below it and can be shadowed. That is why a
propertycannot be overridden by pokingt.__dict__['x'] = value, while an ordinary method — functions are non-data descriptors — genuinely can be shadowed by an instance attribute of the same name (t.save = Nonemakest.save()fail for that one instance only).class DataDesc: def __get__(self, obj, objtype=None): return "from descriptor" def __set__(self, obj, value): obj.__dict__["a"] = value class NonDataDesc: def __get__(self, obj, objtype=None): return "from descriptor" class Thing: a = DataDesc() b = NonDataDesc() t = Thing() t.__dict__["a"] = "from instance dict" t.__dict__["b"] = "from instance dict" t.a # "from descriptor" — data descriptor beats the instance dict t.b # "from instance dict" — non-data descriptor is shadowed by the instance dictHow
propertyuses thispropertyis a built-in data descriptor that wraps your getter/setter/deleter functions:class my_property: def __init__(self, fget): self.fget = fget def __get__(self, obj, objtype=None): if obj is None: return self return self.fget(obj) # call your getter with the instance def __set__(self, obj, value): raise AttributeError("can't set attribute") # <- this is what makes it a DATA descriptor class Circle: def __init__(self, r): self.r = r @my_property def area(self): return 3.14159 * self.r ** 2 c = Circle(10) c.area # 314.159 — getter runs c.area = 5 # AttributeErrorEven a read-only property still defines
__set__(it just raises). That is deliberate: it keepspropertya data descriptor, soc.area = 5can’t be silently absorbed intoc.__dict__and shadow the getter forever.Why
cached_propertyis the mirror imagefunctools.cached_propertydefines only__get__on purpose — being non-data is what lets it cache:class cached_property: def __init__(self, func): self.func = func self.name = func.__name__ def __get__(self, obj, objtype=None): if obj is None: return self value = self.func(obj) obj.__dict__[self.name] = value # write the result into the instance dict return value class Dataset: @cached_property def stats(self): print("computing...") return sum(range(1_000_000)) d = Dataset() d.stats # "computing..." then the value d.stats # just the value — descriptor is never called againFirst access: nothing in
d.__dict__, so lookup falls through to the non-data descriptor, which computes the value and stores it ind.__dict__. Every later access findsd.__dict__["stats"]at step 2, before step 3, and never re-runs the computation. Ifcached_propertywere a data descriptor, step 1 would always win, the cache would be unreachable, and caching would be impossible. That asymmetry — data descriptors can’t be shadowed, non-data descriptors can — is the entire mechanism.- A data descriptor defines
12Where a validating descriptor must store its value — why `self.value` is shared across all instances
#c-where-a-validating-descriptor-must-store-its-value-why-selfvThis is the classic gotcha in this whole area: a descriptor object is created exactly once, at class-definition time, and that single instance is then shared across every instance of the owning class that uses it — so naively storing the value as
self.valuedirectly on the descriptor’s own instance inside__set__means every single instance of the owning class ends up sharing that one same underlying stored value, since there’s fundamentally only one descriptor object in existence, not one per owning-class instance. The correct approaches instead store data per owning-instance, keyed by that instance itself — commonly via aWeakKeyDictionary(a dict that doesn’t prevent its keys from being garbage collected, avoiding creating an unintended memory-retention problem), or, more commonly today, storing directly into the instance’s own__dict__under a distinct, private key name (instance.__dict__[self.name] = value), using__set_name__to reliably know that name.13`__delete__` on a descriptor — resource cleanup and blocking deletion
#c-__delete__-on-a-descriptor-resource-cleanup-and-blocking-del__delete__is invoked when someone doesdel instance.attron an attribute managed by the descriptor. A real, legitimate scenario: a descriptor managing an expensive or externally-held resource (an open connection, a temporary file) that genuinely needs explicit, custom cleanup semantics distinct from merely “the reference to the value is now gone” — or, alternatively, a descriptor deliberately enforcing that a particular managed attribute can never be deleted at all, achieved simply by having a custom__delete__that unconditionally raisesAttributeErrorto actively block the operation.14How `cached_property` exploits non-data descriptor shadowing to cache
#c-how-cached_property-exploits-non-data-descriptor-shadowing-tcached_property.__get__computes its value exactly once, and then manually writes that value directly intoinstance.__dict__[name]itself — and becausecached_propertydeliberately implements only__get__(no__set__, making it a non-data descriptor), that freshly-written instance__dict__entry now correctly takes priority over the descriptor on every subsequent lookup, per the usual non-data-descriptor priority ordering — meaning the descriptor’s own__get__is simply never even invoked again after the first access. Had it instead been implemented as a data descriptor (defining a__set__, even a trivial or unused one), it would win priority over the instance__dict__forever, unconditionally — this convenient shadowing trick wouldn’t work at all, and the implementation would instead be forced to add explicit, manual “have I already computed this” checking logic inside__get__on every single call, just to achieve the same caching effect.15`property` internals — `fget`/`fset`/`fdel`, and why `@x.setter` creates a new property object
#c-property-internals-fgetfsetfdel-and-why-xsetter-creates-a-neA
propertyobject internally stores three separate callables:fget(the getter function),fset(the setter, orNoneif none was supplied), andfdel(the deleter, similarly optional). The genuinely important mechanical detail:@x.setter(used to attach a setter after the fact, in the common@property/@x.setterdecorator pattern) does not mutate the existing property object in place — it instead callsx.setter(new_fset_func), which returns an entirely newpropertyobject, carrying over the same existingfget/fdelbut now paired with the newfset— and it’s that new object that gets rebound to the same class attribute name, effectively replacing the old one. This exact detail is what causes the specific subclassing gotcha explored next.16Overriding only a property's setter in a subclass — the broken attempt and the fix
#c-overriding-only-a-propertys-setter-in-a-subclass-the-broken-The naive, broken attempt: someone in the subclass tries to define a fresh
@property-decorated method containing only setter logic, or otherwise tries something like@BaseClass.x.setterawkwardly bolted onto the subclass — the common failure mode is that they end up completely redefiningxas a genuinely new, separate property in the subclass, entirely losing the inherited getter’s original logic in the process, or hitting a confusingAttributeError: can't set attributebecause they’ve inadvertently created a fresh property object with only a setter and no accompanying getter at all. The correct fix, tying directly back to how@x.setterworks:x = BaseClass.x.setter(new_setter_func), written explicitly in the subclass’s own body — this deliberately starts from the base class’s existing property object (preserving its originalfget) and layers only the new setter on top of it, rather than accidentally starting an entirely fresh, disconnected property from scratch.17Property getters re-run on every access — the invisible performance trap at the call site
#c-property-getters-re-run-on-every-access-the-invisible-perforAn ordinary
property’s getter (fget) genuinely re-runs on every single access, unless you deliberately reach forcached_propertyinstead specifically to avoid that. Sinceobj.valuereads exactly the same, syntactically, whethervalueis a plain stored attribute or a property backed by arbitrarily expensive logic — this is precisely the intended design goal, the “uniform access principle” (letting an attribute’s implementation change from plain storage to computed logic later without breaking any calling code’s syntax) — it also means an expensive property getter is a genuinely easy-to-miss performance trap for anyone reading calling code without separately checking the class’s own definition. A property that looks perfectly innocuous at the call site but secretly performs a network call or database query on every single access is a real, common, and genuinely nasty production performance bug precisely because of this syntactic invisibility.18A getter-only property is still a data descriptor — why assignment raises `AttributeError`
#c-a-getter-only-property-is-still-a-data-descriptor-why-assignYes, and the precise mechanism is worth knowing exactly: a getter-only
propertyis genuinely still a data descriptor —propertyinternally defines__set__regardless of whether you actually supplied anfsetfunction, and that internal__set__simply, immediately raisesAttributeErrorwheneverfsethappens to beNone. This exact detail is why a read-only property genuinely, actively blocks assignment attempts (raising an error) rather than silently allowing the instance’s own__dict__to quietly shadow it the way a genuine non-data descriptor would — connecting “property has no setter” directly to “data descriptors always win over instance__dict__” is exactly the kind of synthesis that surprises people who assume “no setter defined” simply means “nothing in particular happens” rather than “a__set__genuinely exists, and it actively rejects.”19Combining non-cooperative mixins — why `super()` fails and you must call each parent by name
#c-combining-non-cooperative-mixins-why-super-fails-and-you-mussuper()only genuinely works correctly for cooperative multiple inheritance — meaning every single class along the relevant MRO chain itself, internally, callssuper()to explicitly pass control onward to the next class in line. IfMixin1.__init__andMixin2.__init__were each independently written with no awareness ofsuper()'s cooperative chaining at all, callingsuper().__init__()from your own combined subclass only ever reaches the first one of them in the MRO and stops right there — since that first mixin has genuinely no built-in awareness that it’s supposed to forward the call onward to anything further. The correct fix here: call each parent explicitly, by name, deliberately bypassingsuper()'s MRO-based chaining entirely for this specific case —Mixin1.__init__(self, ...)followed byMixin2.__init__(self, ...). This is precisely the sameBase.method(self)vs.super().method()distinction explored above, now applied specifically to the real-world “these third-party classes genuinely weren’t written to cooperate with each other” scenario — where it’s genuinely not merely an alternative stylistic choice, but the only approach that actually, correctly works.20Using `super(SomeSpecificClass, self)` to deliberately skip ahead in the MRO
#c-using-supersomespecificclass-self-to-deliberately-skip-aheadYes, genuinely —
super(SomeSpecificClass, self).method()deliberately begins its MRO-based lookup starting from immediately afterSomeSpecificClassin the chain, rather than starting fromself’s own actual concrete type as zero-argumentsuper()normally would — so deliberately passing a class positioned further down the hierarchy than “my own immediate enclosing class” lets you intentionally jump past whatever intermediate class or classes happen to sit between them in the computed MRO. A rare, but genuinely real, scenario: working around a poorly-behaved intermediate class somewhere in a hierarchy you don’t fully control yourself (e.g., a third-party library’s own mixin containing a buggy, or simply undesired, implementation of some method) — where you specifically need to reach directly past it, to its own parent’s implementation instead — a deliberate, if admittedly slightly uncomfortable, genuine escape hatch, not something to reach for casually by default.21Why cooperative `__init__` chains forward `**kwargs` rather than named arguments
#c-why-cooperative-__init__-chains-forward-kwargs-rather-than-nGenuinely cooperative
__init__chains conventionally accept and forward**kwargs(rather than each class’s own specific, individually-named parameters) precisely because any given class positioned somewhere in the diamond fundamentally has no reliable way of knowing, in advance, exactly which arguments other classes further along the same MRO chain might separately, independently need. If a middle class in that chain only forwards along the specific set of arguments it personally recognizes and needs, silently dropping everything else, any sibling class appearing later in the chain that was genuinely expecting one of those now-dropped arguments will break — typically raising a “missing required argument” error — even though the original, top-level caller genuinely did pass that argument correctly in the very first place. This is the real, concrete mechanical reason behind the common “just consistently use**kwargsthroughout cooperative__init__chains” convention, rather than it being merely arbitrary stylistic advice with no underlying justification.