Skip to content

Classes: MRO, super(), descriptors & metaclasses

Senior

C3 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

  1. 01Method Resolution Order and C3 linearization, with diamond inheritance

    MRO (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): 
        pass
    

    This is a diamond inheritance shape — D inherits from both B and C, which both inherit from A, forming a diamond if you draw the class relationships. C3 linearization produces the MRO D → B → C → A → object (you can see this yourself via D.__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 (B before C, since class D(B, C) listed B first). So D().f() returns "B" — Python finds f on B before it ever reaches C or A in the search order, even though C also defines f and is “closer” to A in 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.

    #c-method-resolution-order-and-c3-linearization-with-diamond-in
  2. 02What `super()` actually returns — an MRO-following proxy, not the parent class

    It returns a proxy object — a genuine super instance, 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 why super() behaves genuinely differently from Base.method(self) in a multi-inheritance diamond — the latter hardcodes a specific class, while super() follows the computed MRO chain instead.

    #c-what-super-actually-returns-an-mro-following-proxy-not-the-p
  3. 03How zero-argument `super()` works — the hidden `__class__` closure cell

    The compiler detects, at compile time, when super() (with no arguments) is used syntactically inside a method, and implicitly injects a hidden closure cell named __class__ into that method — a reference to the enclosing class itself — alongside using the method’s own first argument (conventionally self) as the instance. Effectively, super() is silently rewritten behind the scenes into super(__class__, self) at compile time. A revealing follow-up: if you take a method’s actual function object, strip it entirely out of its enclosing class body, and try calling it standalone with super() inside it, it breaks — because that hidden __class__ cell only ever gets created in the first place because the method was originally compiled directly inside a class body; outside that context, the machinery zero-arg super() silently depends on simply isn’t there.

    #c-how-zero-argument-super-works-the-hidden-__class__-closure-c
  4. 04When the explicit two-argument `super(SomeClass, obj)` form is still needed

    Real, 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).

    #c-when-the-explicit-two-argument-supersomeclass-obj-form-is-st
  5. 05`super()` inside a `classmethod` vs a `staticmethod`

    super() works correctly inside a classmethod — 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 plain staticmethod, though, there simply is no implicit self/cls available at all to supply as super()'s second argument — zero-argument super() 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 assuming super() “just works everywhere inside a class body” unconditionally.

    #c-super-inside-a-classmethod-vs-a-staticmethod
  6. 06`__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 as self and 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 from object) 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 like tuple or str (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).

    #c-__new__-vs-__init__-and-when-to-override-__new__
  7. 07`type` and `object` — the bootstrapping relationship, and why `type` is an instance of itself

    type is 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) returns type itself (assuming no custom metaclass is involved); type(type) also returns type — meaning type is, famously and a little dizzyingly, an instance of itself. Pushing one layer further: type is a subclass of object, while object is simultaneously an instance of type — 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.

    #c-type-and-object-the-bootstrapping-relationship-and-why-type-
  8. 08Why `isinstance(x, type)` is asking "is `x` a class"

    isinstance(x, type) is effectively asking “is x itself a class” — that is, is x’s own type genuinely type (or a subclass/custom metaclass of it). An ordinary instance’s type is not type itself — type(5) is int, and int itself is not type — whereas an actual class’s type genuinely is type (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.

    #c-why-isinstancex-type-is-asking-is-x-a-class
  9. 09The "metaclass conflict" error — cause and resolution

    This 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.

    #c-the-metaclass-conflict-error-cause-and-resolution
  10. 10How `ABCMeta` and `@abstractmethod` block instantiation — a runtime check in metaclass `__call__`

    ABCMeta achieves 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 to SomeClass’s metaclass) — at that exact moment, it checks whether the class being instantiated still has any methods marked with __isabstractmethod__ = True (the marker @abstractmethod applies) that remain unimplemented by that class; if so, it raises TypeError right 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.

    #c-how-abcmeta-and-abstractmethod-block-instantiation-a-runtime
  11. 11Descriptors — data vs non-data, and how `property` is built on them

    A 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 intercepted
    

    Without the descriptor, t.x = 99 would just drop 99 into t.__dict__. With it, __set__ runs instead. That interception is the whole point, and it is the machinery behind @property, classmethod, staticmethod, bound methods, and functools.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 priority object.__getattribute__ applies for t.name:

    1. Look name up on type(t).__mro__ (class attributes only, first match wins). If the match is a data descriptor, call its __get__ and return — unconditionally, even if t.__dict__ also has name.
    2. Otherwise, if name is in t.__dict__, return that value as-is.
    3. 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.
    4. 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 property cannot be overridden by poking t.__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 = None makes t.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 dict
    

    How property uses this

    property is 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    # AttributeError
    

    Even a read-only property still defines __set__ (it just raises). That is deliberate: it keeps property a data descriptor, so c.area = 5 can’t be silently absorbed into c.__dict__ and shadow the getter forever.

    Why cached_property is the mirror image

    functools.cached_property defines 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 again
    

    First access: nothing in d.__dict__, so lookup falls through to the non-data descriptor, which computes the value and stores it in d.__dict__. Every later access finds d.__dict__["stats"] at step 2, before step 3, and never re-runs the computation. If cached_property were 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.

    #c-descriptors-data-vs-non-data-and-how-property-is-built-on-th
  12. 12Where a validating descriptor must store its value — why `self.value` is shared across all instances

    This 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.value directly 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 a WeakKeyDictionary (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.

    #c-where-a-validating-descriptor-must-store-its-value-why-selfv
  13. 13`__delete__` on a descriptor — resource cleanup and blocking deletion

    __delete__ is invoked when someone does del instance.attr on 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 raises AttributeError to actively block the operation.

    #c-__delete__-on-a-descriptor-resource-cleanup-and-blocking-del
  14. 14How `cached_property` exploits non-data descriptor shadowing to cache

    cached_property.__get__ computes its value exactly once, and then manually writes that value directly into instance.__dict__[name] itself — and because cached_property deliberately 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.

    #c-how-cached_property-exploits-non-data-descriptor-shadowing-t
  15. 15`property` internals — `fget`/`fset`/`fdel`, and why `@x.setter` creates a new property object

    A property object internally stores three separate callables: fget (the getter function), fset (the setter, or None if none was supplied), and fdel (the deleter, similarly optional). The genuinely important mechanical detail: @x.setter (used to attach a setter after the fact, in the common @property/@x.setter decorator pattern) does not mutate the existing property object in place — it instead calls x.setter(new_fset_func), which returns an entirely new property object, carrying over the same existing fget/fdel but now paired with the new fset — 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.

    #c-property-internals-fgetfsetfdel-and-why-xsetter-creates-a-ne
  16. 16Overriding only a property's setter in a subclass — the broken attempt and the fix

    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.setter awkwardly bolted onto the subclass — the common failure mode is that they end up completely redefining x as a genuinely new, separate property in the subclass, entirely losing the inherited getter’s original logic in the process, or hitting a confusing AttributeError: can't set attribute because 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.setter works: 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 original fget) and layers only the new setter on top of it, rather than accidentally starting an entirely fresh, disconnected property from scratch.

    #c-overriding-only-a-propertys-setter-in-a-subclass-the-broken-
  17. 17Property getters re-run on every access — the invisible performance trap at the call site

    An ordinary property’s getter (fget) genuinely re-runs on every single access, unless you deliberately reach for cached_property instead specifically to avoid that. Since obj.value reads exactly the same, syntactically, whether value is 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.

    #c-property-getters-re-run-on-every-access-the-invisible-perfor
  18. 18A getter-only property is still a data descriptor — why assignment raises `AttributeError`

    Yes, and the precise mechanism is worth knowing exactly: a getter-only property is genuinely still a data descriptor — property internally defines __set__ regardless of whether you actually supplied an fset function, and that internal __set__ simply, immediately raises AttributeError whenever fset happens to be None. 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.”

    #c-a-getter-only-property-is-still-a-data-descriptor-why-assign
  19. 19Combining non-cooperative mixins — why `super()` fails and you must call each parent by name

    super() only genuinely works correctly for cooperative multiple inheritance — meaning every single class along the relevant MRO chain itself, internally, calls super() to explicitly pass control onward to the next class in line. If Mixin1.__init__ and Mixin2.__init__ were each independently written with no awareness of super()'s cooperative chaining at all, calling super().__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 bypassing super()'s MRO-based chaining entirely for this specific case — Mixin1.__init__(self, ...) followed by Mixin2.__init__(self, ...). This is precisely the same Base.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.

    #c-combining-non-cooperative-mixins-why-super-fails-and-you-mus
  20. 20Using `super(SomeSpecificClass, self)` to deliberately skip ahead in the MRO

    Yes, genuinely — super(SomeSpecificClass, self).method() deliberately begins its MRO-based lookup starting from immediately after SomeSpecificClass in the chain, rather than starting from self’s own actual concrete type as zero-argument super() 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.

    #c-using-supersomespecificclass-self-to-deliberately-skip-ahead
  21. 21Why cooperative `__init__` chains forward `**kwargs` rather than named arguments

    Genuinely 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 **kwargs throughout cooperative __init__ chains” convention, rather than it being merely arbitrary stylistic advice with no underlying justification.

    #c-why-cooperative-__init__-chains-forward-kwargs-rather-than-n