AI EngineeringZero to ProductionHome·About·What’s new·Contact
Appendix · Python for AI Agents · Part 8

The object model & metaclasses

P3 taught classes; this part explains the machinery underneath them. You will see how attribute access really resolves (type, then instance, then __getattr__), write a descriptor and discover that @property and even plain methods are descriptors, watch the MRO get computed by C3 linearization on a diamond, measure what __slots__ buys you, and finally reach the top of the tower: metaclasses, where a class is itself an instance of type and you can hook class creation. We prefer __init_subclass__ where it suffices and reserve metaclasses for when it does not. Every mechanism is demonstrated with runnable code and honest output.

⏱️ ~2.5 hours🎯 Advanced → Industry🧩 the object modelrunnable CPython 3.13

Learning objectives

  • Use the dunder protocol to make objects work with operators, ordering, hashing and built-ins.
  • Trace attribute lookup through __getattribute__, the class MRO, and the __getattr__ fallback.
  • Write a descriptor and explain why @property and methods are themselves descriptors.
  • Compute an MRO by C3 linearization and explain cooperative super() on a diamond.
  • Apply __slots__ and measure its memory and attribute-restriction effects.
  • Build a class with a metaclass, and choose __init_subclass__ when it suffices.

1 · The dunder protocol

Here's a moment nearly every Python learner hits. You write a tidy little class — say a Money type — and then you try a + b and get TypeError: unsupported operand type(s). Or you print an instance and see the useless <__main__.Money object at 0x7f8a...>. Or you put two "equal" objects in a set and both survive. It feels like Python is treating your class as a second-class citizen — and in a sense it is, until you tell it otherwise.

The fix is one of the most beautiful ideas in the language, and once it clicks you'll see it everywhere. Python's operators and built-ins don't have special knowledge of int or list baked in. Instead, they're protocols: a + b is just sugar that asks the left operand "do you know how to add?" by calling a.__add__(b); len(x) asks x.__len__(); a < b asks a.__lt__(b). Think of the double-underscore ("dunder") methods as the electrical sockets in the wall: the built-in syntax is the plug, and if your class exposes the matching socket, the plug just works. Your Money was failing not because it was inferior, but because it hadn't wired up any sockets.

The everyday payoff is that your objects stop being awkward outsiders and start behaving like the built-ins you already trust. Implement __repr__ and they print readably in the debugger; implement __eq__ and == compares by meaning instead of by memory address; implement __lt__ and sorted() just works; implement __hash__ and they can live in sets and dict keys. The common mistake — and it's the subject of a knowledge-check below — is defining __eq__ and forgetting __hash__: Python then quietly sets __hash__ = None to protect the "equal things hash equally" invariant, and your object becomes unhashable. So treat __eq__ and __hash__ as a pair that must agree. To save you writing all six comparison operators by hand, @total_ordering derives the rest from __eq__ plus one of __lt__/__gt__. Now the precise picture: implement the right dunders and your own class becomes a first-class citizen — a + b calls a.__add__(b), len(x) calls x.__len__(), a < b calls a.__lt__(b), and membership in a set/dict uses __hash__ and __eq__.

the plug (syntax) the socket (your dunder) a + b len(x) a < b print(a) / repr(a) a in {…} __add__(self, o) __len__(self) __lt__(self, o) __repr__(self) __hash__ + __eq__ define the socket and the built-in plug just works Operators are protocols, not privileges. Each piece of built-in syntax on the left simply calls the matching dunder method on the right — so a class that defines those methods behaves like a built-in.
🗺️ How to read this diagram

Read it left-to-right: pick any syntax on the left and follow its arrow to the dunder method that actually runs.

  • The blue boxes on the left are ordinary Python syntax and built-ins you already use — a + b, len(x), a < b, printing, membership.
  • The arrows are the desugaring: Python translates each surface form into a method call. a + b is literally a.__add__(b).
  • The green boxes on the right are the "sockets" — the dunder methods your class defines. Wire up the socket and the corresponding plug lights up; leave it empty and you get the TypeError from the story.

In short: There is no special-casing for built-in types — your class earns first-class behavior purely by implementing the dunders each operator calls.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it
dunder.py# Dunders let your objects plug into built-in syntax and protocols.
from functools import total_ordering

@total_ordering
class Money:
    def __init__(self, cents): self.cents = cents
    def __repr__(self): return f"Money({self.cents})"          # repr()
    def __eq__(self, o): return isinstance(o, Money) and self.cents == o.cents
    def __lt__(self, o): return self.cents < o.cents            # + total_ordering => <=,>,>=
    def __add__(self, o): return Money(self.cents + o.cents)    # a + b
    def __hash__(self): return hash(self.cents)                 # usable in set/dict

a, b = Money(150), Money(99)
print(a + b)                 # Money(249)  via __add__
print(a > b, a == Money(150))# True True   via __lt__/@total_ordering and __eq__
print(sorted([a, b]))        # [Money(99), Money(150)]  via __lt__
print(len({a, Money(150)}))  # 1  via __hash__/__eq__ (deduped)
Money(249)
True True
[Money(99), Money(150)]
1

2 · Attribute lookup

A debugging story that will feel familiar. You have a class with a @property called balance. In __init__ a colleague writes self.balance = 0 and… nothing breaks, but the property's validation never fires. Someone else adds __getattr__ to supply defaults and now a typo'd attribute silently returns a default instead of raising — bugs hide for weeks. Both mysteries dissolve the moment you understand the one thing this section teaches: there is a strict, knowable order in which Python looks up obj.name, and it is not the order most people guess.

Here's the mental model. When you type obj.name, Python doesn't just peek inside the object. It runs a little receptionist named __getattribute__ that checks a list of places in a fixed priority. Picture asking the front desk of an office for "the report": the receptionist has standing orders about who to ask first — a locked master file (a data descriptor on the type), then the person's own desk drawer (the instance's __dict__), then the shared filing cabinets down the hall (class attributes along the MRO). Crucially, the master file outranks your desk drawer — which is exactly why self.balance = 0 couldn't quietly clobber the balance property: a property is a data descriptor, and it sits higher on the list than your instance dict.

Only when every normal place comes up empty does Python fall back to a special hook, __getattr__ (note: no "-ibute"), the "I couldn't find it anywhere — any last ideas?" method. That's why a defaulting __getattr__ can mask typos: a misspelled name genuinely isn't found, so the fallback answers instead of erroring. The classic trap lives one level up: if you override __getattribute__ — the receptionist itself, which runs on every single access — and you try to read self.something inside it the ordinary way, you re-enter __getattribute__, which reads self.something, which re-enters… an infinite recursion that blows the stack. The rule is to always delegate up the chain with super().__getattribute__(name). With that map in hand, the formal statement reads cleanly: every attribute access obj.name goes through __getattribute__, which searches in a defined order: data descriptors on the type, then the instance __dict__, then non-data descriptors / class attributes along the MRO. Only if all of that fails does Python call __getattr__ — the fallback hook. Overriding __getattribute__ intercepts everything and must delegate via super() or it recurses forever.

Try it
lookup.py# Attribute lookup: __getattribute__ runs for EVERY access; __getattr__ is the
# fallback that runs ONLY when normal lookup fails.
class Config:
    def __init__(self): self.host = "localhost"
    def __getattr__(self, name):
        # only reached when 'name' is not found the normal way
        return f"<default:{name}>"

c = Config()
print(c.host)        # localhost   -> found normally, __getattr__ NOT called
print(c.port)        # <default:port> -> missing, so __getattr__ handles it

# __getattribute__ intercepts everything (use with care -> easy infinite loops)
class Logged:
    def __getattribute__(self, name):
        # MUST delegate via super() to avoid recursing forever
        val = super().__getattribute__(name)
        print(f"  access {name!r}")
        return val
    def __init__(self): self.x = 1

obj = Logged()
_ = obj.x            # prints the access line, then returns 1
localhost
<default:port>
  access 'x'
obj.name access data descriptor? type first instance dict? then instance class / MRO? then classes __getattr__ last resort
🗺️ How to read this diagram

Read it strictly left-to-right, following the brand-coloured arrows: this is the fixed order Python tries when you touch obj.name, and it stops at the first box that answers "yes".

  • The leftmost box is the trigger — obj.name access. Everything to its right is the lookup chain that access kicks off.
  • The arrows impose priority, not just flow: first check the class for a data descriptor ("type first"), then the instance dict ("then instance"), then walk the class / MRO ("then classes"). Each box wins over the ones to its right if it has the attribute.
  • The rightmost box is the payoff — __getattr__, the "last resort" that only fires if every earlier box came up empty.

In short: attribute access isn't a single dict peek — it's this ordered chain, and a data descriptor sits at the front, which is why @property can't be shadowed by an instance attribute.

3 · Descriptors — how property & methods work

You've almost certainly used descriptors without knowing it. Every time you wrote @property to turn a method into a computed attribute, every time you called self.method() and self magically arrived as the first argument, every @staticmethod and @classmethod — all of it is one underlying mechanism wearing different hats. Learners often treat these as four unrelated pieces of magic to memorize. They are not. They are a single idea, and this section pulls back the curtain so the "magic" becomes something you can build yourself in ten lines.

The idea is a managed attribute. Normally an attribute is dumb storage: you set it, you read it, done. A descriptor is an attribute that's actually a small object with opinions — it defines __get__ (what happens when you read it) and optionally __set__/__delete__ (what happens when you write or delete it), and it lives on the class, not the instance. The everyday analogy is a smart thermostat versus a plain dial: with a plain dial, setting "30°C" just stores 30. A thermostat intercepts the set — it can reject an out-of-range value, log the change, or convert units — because the act of assigning runs its code, not raw storage. That interception, applied to every instance of the class automatically, is the whole feature.

Why does this matter beyond being a neat trick? Because it's the cleanest way to write validation and computed fields once and reuse them across many classes — a "field must be non-negative" or "must be an int" rule expressed a single time as a descriptor, then dropped onto any class as a field. It's how libraries like Django's ORM, SQLAlchemy, and Pydantic express their model fields. And here's the payoff that ties the section together: a plain function is itself a descriptor — its __get__ is what binds self to produce a bound method — so understanding descriptors explains where self comes from at the deepest level. The one gotcha to file away (it gets its own callout below): whether a descriptor defines __set__ decides whether an instance attribute can shadow it — a "data descriptor" like @property can't be accidentally overwritten by self.x = ..., while a "non-data" one can. Now the precise definition: a descriptor is any object that defines __get__ (and optionally __set__/__delete__) and is stored on a class. It intercepts attribute access for every instance. This is not an exotic feature — it is the mechanism behind @property, classmethod, staticmethod, and even ordinary methods: a plain function is a descriptor whose __get__ binds self. Writing one yourself demystifies all of them.

plain attribute self.x = 30 stored as-is (30) descriptor attribute self.x = 30 __set__(obj, 30) valid → store it bad → raise error A descriptor intercepts the assignment. A plain attribute stores whatever you give it; a descriptor's __set__ runs your code first, so it can validate, convert, or reject the value.
🗺️ How to read this diagram

Compare the two rows top-to-bottom: the same self.x = 30 takes very different paths depending on whether x is a plain attribute or a descriptor.

  • The top row is a normal attribute — the assignment is dumb storage: 30 goes straight into the instance, no checks.
  • The bottom row is a descriptor: the same assignment is diverted through __set__(obj, 30), the "smart thermostat" that runs your code.
  • From __set__ the flow branches — the green box (valid value → stored) or the red box (out-of-range → raise). That branch is exactly the validation a plain attribute can't do.

In short: Put a descriptor on the class and every assignment to that field is a method call you control — write the rule once, reuse it on any class.

Try it
descriptor.py# A descriptor is any object defining __get__/__set__/__delete__ that lives on a
# CLASS. It controls attribute access for instances. @property and methods are
# descriptors under the hood.
class Positive:
    def __set_name__(self, owner, name):        # remember the attribute's name
        self.store = "_" + name
    def __get__(self, obj, objtype=None):
        if obj is None: return self             # accessed on the class
        return getattr(obj, self.store)
    def __set__(self, obj, value):
        if value < 0:
            raise ValueError(f"{self.store[1:]} must be >= 0")
        setattr(obj, self.store, value)

class Account:
    balance = Positive()                        # a data descriptor on the class
    def __init__(self, b): self.balance = b     # goes through __set__ -> validated

acct = Account(100)
print("balance:", acct.balance)                 # via __get__
try:
    acct.balance = -5                           # via __set__ -> guard trips
except ValueError as e:
    print("rejected:", e)

# Proof that @property and methods ARE descriptors:
class Demo:
    @property
    def p(self): return 1
    def m(self): return 2
print("property is descriptor:", hasattr(Demo.__dict__["p"], "__get__"))
print("function is descriptor:", hasattr(Demo.__dict__["m"], "__get__"))
balance: 100
rejected: balance must be >= 0
property is descriptor: True
function is descriptor: True
Data vs non-data descriptorsA descriptor with __set__/__delete__ is a data descriptor and wins over the instance __dict__; one with only __get__ is non-data and can be shadowed by an instance attribute. That precedence is exactly why a @property (a data descriptor) can't be accidentally overwritten by self.x = ....

4 · The MRO & C3 linearization

Picture the bug that made someone finally learn this. A class inherits from two mixins, each of which inherits from a common base — the classic "diamond" shape. Someone calls a method, expects the left mixin's version, and gets the right one's. Or worse: a shared base's __init__ runs twice, or not at all, and state gets half-initialized. The instinct is to blame Python for being weird about multiple inheritance. It isn't weird — it's following a precise, published algorithm, and once you can read that algorithm the diamond stops being scary.

The core problem is unavoidable: if a class has two parents and you ask for an attribute, Python must decide which parent to check first — it needs to flatten a branching family tree into a single straight line to walk. That line is the Method Resolution Order (MRO), and Python computes it with an algorithm called C3 linearization. Don't let the name intimidate you; the promises it makes are common-sense. Think of it like a seating order at a formal dinner that has to satisfy two etiquette rules simultaneously: a child is always seated before its parents (you check the subclass before the base), and the order you listed the parents left-to-right is respected. C3 is simply the unique ordering that honors both rules — and if no ordering can honor both, it refuses to seat anyone at all.

That "refuses" part is a feature, not a failure: if you write contradictory base orderings — (X, Y) in one place and (Y, X) in another that later combine — Python raises TypeError: Cannot create a consistent method resolution order at class-definition time, telling you your hierarchy is genuinely ambiguous before it can bite you at runtime. The single most important correction to make in your head: super() does not mean "my parent class." It means "the next class along this instance's MRO" — which may be a sibling you never inherited from directly. That surprising-but-correct behavior is the entire engine behind cooperative multiple inheritance: when every class's __init__ calls super().__init__(), control walks the whole MRO exactly once, so every mixin gets to do its part and the shared base runs a single time. Skip one super() call and the chain breaks. Formally: with multiple inheritance Python must pick a single order to search bases. It uses C3 linearization, which guarantees a consistent order that (1) keeps each class before its parents and (2) preserves the left-to-right order you wrote. The result is cls.__mro__. super() does not mean “the literal base class” — it means “the next class in the MRO,” which is what makes cooperative multiple inheritance (every __init__ calling super().__init__()) work.

the diamond you wrote A B C D C3 the single line Python searches (MRO) D B C A object child before parents · left-to-right preserved C3 flattens the tree into one line. The branching diamond on the left becomes the single ordered sequence D → B → C → A → object that attribute lookup and super() walk exactly once.
🗺️ How to read this diagram

Start on the left with the family tree you actually wrote, then follow the C3 arrow to the flattened order on the right.

  • The left diamond is the hierarchy: D inherits from B and C, both of which inherit from A. Two paths up to A — the ambiguity Python must resolve.
  • The C3 arrow in the middle is the linearization step: it collapses those branches into one straight sequence.
  • The right row is cls.__mro__ — the exact order lookup and super() follow: each child comes before its parents and your left-to-right base order (B then C) is preserved, ending at object.

In short: super() doesn't jump to a parent — it steps to the next box in this line, which is why the shared base A runs exactly once.

Try it
mro.py# The MRO (method resolution order) is the single, linear order Python searches
# for attributes. It is computed by the C3 linearization algorithm.
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):        # the classic diamond
    pass

print([cls.__name__ for cls in D.__mro__])   # D, B, C, A, object
print(D().who())                             # 'B' -> first match along the MRO

# super() follows the MRO, NOT the literal base — cooperative multiple inheritance
class Base:
    def __init__(self): self.log = ["Base"]
class L(Base):
    def __init__(self): super().__init__(); self.log.append("L")
class R(Base):
    def __init__(self): super().__init__(); self.log.append("R")
class LR(L, R):
    def __init__(self): super().__init__(); self.log.append("LR")

print(LR().log)                              # ['Base', 'R', 'L', 'LR']
['D', 'B', 'C', 'A', 'object']
B
['Base', 'R', 'L', 'LR']
Inconsistent hierarchies are rejectedIf no C3-consistent order exists (e.g. bases ordered (X, Y) in one place and (Y, X) in another, then combined), Python raises TypeError: Cannot create a consistent method resolution order at class-definition time — a real design signal, not a bug to work around.

5 · __slots__ — trading flexibility for footprint

Here's a war story from anyone who's built something at scale. A service parses a big file into a few million small objects — one per row, per token, per graph node. It works fine on the 10,000-row test file. In production the real file has 8 million rows, the process balloons past its memory limit, and the container gets OOM-killed. The code is correct. The objects are the problem: each innocent little instance is secretly lugging around a whole dictionary. __slots__ is the one-line fix that would have prevented it.

To feel why, you have to know a hidden cost of ordinary Python objects. By default every instance carries its own __dict__ — a full hash table — so it can accept any attribute name at any time (self.whatever = 1 just works). That flexibility is wonderful for a handful of objects and ruinous for millions, because a dict has real per-object overhead beyond the values you actually store. The analogy: giving every instance a __dict__ is like handing every guest at a stadium their own filing cabinet to hold three sticky notes. Fine for a dinner party; catastrophic for 80,000 people. __slots__ replaces the filing cabinet with a few labeled pegs — a fixed, compact set of storage slots the class declares up front. No dict, far less memory per object, and slightly faster attribute access because it's a direct offset instead of a hash lookup.

The trade you're making — and it's right there in the section title — is flexibility for footprint. Once you declare __slots__ = ('a', 'b', 'c'), those are the only attributes instances may have; self.d = 4 now raises AttributeError. Most of the time that restriction is a quiet bonus (it catches typo'd attribute names that would otherwise silently create junk), but it's a real behavior change to be aware of. The common mistakes: reaching for slots on a class you only ever make a few of (pointless — save it for the high-count objects), and forgetting that slots interacts with inheritance and with @property, so if you genuinely need dynamic attributes on a slotted class you must add '__dict__' back into the slots tuple. And a fairness note the lab will echo: the exact megabytes and ratio you'll measure depend on your CPython version and platform — the direction (slots use less) is reliable, the precise number is not. The precise statement: by default each instance owns a __dict__, which is flexible but costs memory and a hash lookup per attribute. Declaring __slots__ tells Python to store a fixed set of attributes in a compact layout instead: no per-instance __dict__, lower memory, slightly faster access — and no adding attributes that aren't declared. It matters when you allocate millions of small objects (graph nodes, records, tokens).

default instance WithDict(1,2,3) __dict__ (a hash table) a→1 b→2 c→3 + spare buckets, per object slotted instance WithSlots(1,2,3) __slots__ = ('a','b','c') 1 2 3 three labelled pegs · no dict same 3 values — one carries a whole hash table, one does not × millions of objects = the memory difference Same data, two footprints. The default object lugs a full __dict__ per instance; __slots__ stores the fixed fields in a compact layout — no dict, less memory, faster access.
🗺️ How to read this diagram

Compare the two boxes side by side: both hold the identical values 1, 2, 3, but the storage underneath is very different.

  • The left (red-tinted) box is a normal instance. Its values live inside a per-object __dict__ — a hash table that carries spare buckets and overhead so you can add any attribute later.
  • The right (green) box is a slotted instance: __slots__ declares the field names once, and each value sits in a compact fixed peg with no dict at all.
  • The bottom line is the payoff: for one object the difference is tiny, but multiplied across millions of small objects it's the gap between fitting in memory and getting OOM-killed.

In short: __slots__ trades the freedom to add arbitrary attributes for a smaller, faster fixed layout — worth it exactly when object counts are huge.

Try it
slots.py# __slots__ replaces the per-instance __dict__ with fixed storage: less memory,
# slightly faster attribute access, and no arbitrary new attributes.
import sys, tracemalloc

class WithDict:
    def __init__(self, a, b, c): self.a, self.b, self.c = a, b, c

class WithSlots:
    __slots__ = ("a", "b", "c")
    def __init__(self, a, b, c): self.a, self.b, self.c = a, b, c

d, s = WithDict(1, 2, 3), WithSlots(1, 2, 3)
print("dict instance has __dict__ :", hasattr(d, "__dict__"))   # True
print("slots instance has __dict__:", hasattr(s, "__dict__"))   # False

try:
    s.d = 4                       # not in __slots__
except AttributeError as e:
    print("slots blocks new attr :", type(e).__name__)

def footprint(cls, n=100_000):
    tracemalloc.start()
    keep = [cls(1, 2, 3) for _ in range(n)]
    used, _ = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return used

md, ms = footprint(WithDict), footprint(WithSlots)
print(f"100k objs -> dict {md/1e6:.1f} MB  slots {ms/1e6:.1f} MB")
dict instance has __dict__ : True
slots instance has __dict__: False
slots blocks new attr : AttributeError
100k objs -> dict 10.4 MB  slots 6.4 MB
The exact MB and ratio are machine-dependentYou will reliably see slots use less memory than dict-based instances and reject undeclared attributes; the precise numbers vary by CPython version and platform. Measure on your target. Note slots interacts with inheritance and @property — add '__dict__' to slots if you need both.

6 · Metaclasses — the type of a type

Metaclasses have a fearsome reputation, and it's worth defusing before we start. There's a famous Tim Peters line — "if you wonder whether you need metaclasses, you don't" — and it scares people off. But the reputation is about overuse, not difficulty. The concept itself is a single, almost playful idea, and grasping it completes your model of how Python works. So set the fear aside for one section: we're going to look the dragon in the eye, and it turns out to be a lizard.

Start from something you already accepted in section 1: in Python, everything is an object — numbers, functions, even modules. Now push that one step further, the step most people never take: a class is an object too. And every object has a type. So what is the type of a class? Run type(int) and you get <class 'type'>. The thing that creates classes is itself a class, named type, and a class whose instances are other classes is called a metaclass. The analogy that makes it stick: if a class is a cookie cutter that stamps out cookies (instances), then a metaclass is the machine that stamps out cookie cutters. Ordinary classes are made by the default machine, type; a custom metaclass is your own machine that can tweak each cutter as it's forged.

The concrete mechanism is disarmingly literal. When Python executes a class Foo: ... statement, it is really calling type(name, bases, namespace) under the hood — you can do that call yourself to build a class at runtime with no class keyword at all. Subclass type, override its __new__, and your code runs at class-definition time, once per class, with the power to inspect and rewrite the class before it exists: enforce that required methods are present, auto-register every plugin subclass, inject methods, seal an interface. The honest warning, which the very next section acts on: a metaclass infects the whole hierarchy (every subclass inherits it) and can collide with other metaclasses like ABCMeta, so it's the heaviest tool in this chapter — reach for it only when you must control class construction itself, and prefer the lighter __init_subclass__ whenever that suffices. The keystone, stated precisely: a class is an object, and its type is type. That means type is a metaclass — the thing that builds classes — and you can subclass it to hook class creation. type(name, bases, namespace) is literally what the class statement calls. A custom metaclass runs code at class-definition time: enforce interfaces, auto-register plugins, inject methods.

stamps out ↓ stamps out ↓ metaclass type / PluginMeta class Plugin / int instance obj read it as type(x): the box to the left is the type of the box to the right type(obj) → class · type(class) → metaclass · type(type) → type class Foo: … is literally type(name, bases, namespace) A metaclass is the machine that stamps out classes. If a class is a cookie cutter for instances, its metaclass is the machine that forges the cutter — and a custom one can tweak each class as it is built.
🗺️ How to read this diagram

Read the top row left-to-right as a chain of "stamps out", and read the bottom band as the same chain expressed with type(...).

  • The top row is the manufacturing chain: a metaclass creates a class, which in turn creates instances — cookie-cutter machine → cutter → cookie.
  • The arrows point in the "creates" direction; walk them backwards and each box is the type of the one on its right, which is what type(x) returns.
  • The bottom band makes it literal: type(obj) is the class, type(class) is the metaclass, and type is its own type — and defining a class is really just a call to type(name, bases, namespace).

In short: Subclass type and override __new__ and your code runs while the class itself is being forged — the deepest hook in the object model.

Try it
metaclass.py# A class is an OBJECT whose type is `type`. type is the default metaclass.
class Plain: pass
print(type(Plain))          # <class 'type'>
print(type(type))           # <class 'type'>  (type is its own type)

# type(name, bases, namespace) builds a class dynamically — same as `class`.
Dyn = type("Dyn", (), {"greet": lambda self: "hi"})
print(Dyn().greet())        # hi

# A real metaclass: auto-register every subclass in a plugin registry.
class PluginMeta(type):
    registry = {}
    def __new__(mcls, name, bases, ns, **kw):
        cls = super().__new__(mcls, name, bases, ns)
        if bases:                                   # skip the base itself
            PluginMeta.registry[name.lower()] = cls
        return cls

class Plugin(metaclass=PluginMeta): pass
class CsvLoader(Plugin): pass
class JsonLoader(Plugin): pass

print(sorted(PluginMeta.registry))                  # ['csvloader', 'jsonloader']
print(type(CsvLoader).__name__)                     # PluginMeta
<class 'type'>
<class 'type'>
hi
['csvloader', 'jsonloader']
PluginMeta

7 · Prefer __init_subclass__ when you can

Now the practical counterweight to the section you just read. Here's what actually happens in codebases: an engineer learns metaclasses, is delighted by them, and starts reaching for one every time they want "something to happen when a subclass is defined" — auto-register this tool, check that subclass has a run method, default a class attribute. It works. It also drags a metaclass onto the entire hierarchy, mystifies the next reader, and detonates the day someone tries to also inherit from an abc.ABC (whose ABCMeta now conflicts). Nine times out of ten there is a lighter tool that does the same job in plain sight — and this section is about knowing when to use it.

That tool is __init_subclass__, added in Python 3.6 precisely to absorb the most common metaclass use case. Think of it as the difference between rebuilding the factory (a metaclass) and simply leaving a note for every new hire (__init_subclass__). You define it once on a parent class, and Python calls it automatically each time any subclass is created, handing you the freshly-built subclass so you can register it, validate it, or set defaults on it. It reads like an ordinary method, needs no knowledge of type, composes cleanly with other base classes, and — because it's not a metaclass — never causes metaclass conflicts. It can even accept keyword arguments straight from the class header (class Search(Tool, name="search")), which is a remarkably clean way to configure a subclass at the point of definition.

The decision rule to carry away, and the reason this section sits last: match the tool to the job on the smallest scale that works. Need to react to a subclass being defined — registration, presence checks, defaulting a class attribute? Use __init_subclass__. Need to name a per-class descriptor? That's __set_name__ (section 3). Need to change how instances resolve attributes? Descriptors or __getattr__. Only when you must genuinely control the construction of the class object itself — a custom __new__, preparing the namespace before the body runs, or enforcing the metaclass hierarchy — do you graduate to a full metaclass. The summary table below encodes exactly this ladder. Stated plainly: most "do something when a subclass is defined" needs — registration, validation, defaulting a class attribute — don't require a metaclass at all. __init_subclass__ is a classmethod-like hook on the parent that runs for each subclass, is far easier to read, and composes cleanly. Reach for a metaclass only when you must control the class object's creation itself (custom __new__/namespace) or the metaclass hierarchy.

need to hook a subclass? what exactly must you do? react to it being defined rebuild the class object __init_subclass__ register · validate · default a metaclass custom __new__ · namespace pick the lightest tool that does the job — the left one covers most cases Match the tool to the job. Most "do something when a subclass appears" needs are registration/validation → use the lightweight __init_subclass__; only controlling class construction justifies a metaclass.
🗺️ How to read this diagram

Start at the question at the top and follow the branch that matches what you actually need to do.

  • The top box is the decision: you want something to happen around a subclass — but which mechanism?
  • The left branch (green) is the common case: you only need to react to a subclass being defined — register it, validate it, set a default. __init_subclass__ handles all of that with no metaclass.
  • The right branch (red) is the rare case: you must control the construction of the class object itself — a custom __new__ or namespace prep. Only that justifies a full metaclass, with its hierarchy-wide cost and conflict risk.

In short: Default to the green branch; graduate to a metaclass only when you're genuinely reshaping how the class is built.

Try it
init_subclass.py# __init_subclass__ hooks subclass creation WITHOUT a metaclass — usually the
# right tool. It runs on the PARENT each time a subclass is defined.
class Tool:
    registry = {}
    def __init_subclass__(cls, /, name=None, **kw):
        super().__init_subclass__(**kw)
        cls.tool_name = name or cls.__name__.lower()
        Tool.registry[cls.tool_name] = cls

class Search(Tool, name="search"): pass
class Calc(Tool): pass

print(sorted(Tool.registry))     # ['calc', 'search']
print(Search.tool_name)          # search
['calc', 'search']
search
NeedReach for
Register/validate subclasses, set class attrs__init_subclass__ (simplest)
Customize a per-class descriptor's name__set_name__
Control the class object's construction / namespacea metaclass
Change attribute access on instancesdescriptors / __getattr__

✓ Checkpoint — you can move on when you can…

  • Implement __eq__ + __hash__ + __lt__ and use the object in a set and sorted().
  • Predict when __getattr__ fires vs when normal lookup or a data descriptor wins.
  • Write a descriptor with a validating __set__ and explain why @property is one.
  • Compute the MRO of a diamond and trace a cooperative super().__init__() chain.
  • Explain what __slots__ removes and choose between __init_subclass__ and a metaclass.
✓ Knowledge check

You define __eq__ on a class and suddenly it can no longer be used as a dict key — TypeError: unhashable type. Why, and what is the correct fix?

Show answer
Defining __eq__ makes Python set __hash__ = None automatically, because the invariant “equal objects must have equal hashes” could otherwise be violated. An object with __hash__ = None is unhashable, so it can't be a dict key or set member. Fix: also define __hash__ consistently with __eq__ (hash the same fields you compare) — e.g. def __hash__(self): return hash((self.x, self.y)). If the object is mutable and you want it unhashable on purpose, leave it as is.
✓ Knowledge check

A teammate reaches for a metaclass to auto-register agent tool classes in a registry. Is that the right tool, and what's the lighter alternative?

Show answer
It works, but a metaclass is usually overkill for registration and adds a metaclass to the whole hierarchy (which can conflict with other metaclasses like ABCMeta). The lighter, more readable tool is __init_subclass__ on a base Tool class: it runs once per subclass definition, can read keyword arguments from the class header (class Search(Tool, name="search")), and needs no metaclass. Reserve metaclasses for controlling class construction itself — custom __new__, namespace preparation, or enforcing that the metaclass hierarchy is consistent.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Make a value type printable and comparableBeginner

Context: Domain value objects (money, versions, coordinates) are far nicer when they print readably and compare by value.

Your task: Write a small value class with __repr__ and __eq__ so it prints clearly and compares correctly.

Requirements:

  • __init__ storing two fields
  • __repr__ returning a useful string
  • __eq__ comparing by field values; test equality of two equal instances

💡 Hint: __repr__ should look like a constructor call.

Show solution
Solution
class Point:
    def __init__(self, x, y): self.x, self.y = x, y
    def __repr__(self): return f'Point({self.x}, {self.y})'
    def __eq__(self, o): return isinstance(o, Point) and (self.x, self.y) == (o.x, o.y)

print(Point(1, 2))            # Point(1, 2)
print(Point(1, 2) == Point(1, 2))  # True
print(Point(1, 2) == Point(3, 4))  # False
Point(1, 2)
True
False
Exercise 2 · A defaulting config via __getattr__Intermediate

Context: Config objects are cleaner when missing keys fall back to a default instead of raising.

Your task: Write a Config whose known attributes work normally but whose unknown attributes return a default, using __getattr__.

Requirements:

  • Set one real attribute in __init__
  • __getattr__ returns None (or a marker) for anything missing
  • Show a known attribute and a missing one

💡 Hint: __getattr__ only fires when normal lookup fails.

Show solution
Solution
class Config:
    def __init__(self): self.host = 'localhost'
    def __getattr__(self, name): return None   # default for anything missing

c = Config()
print(c.host)     # localhost  (normal lookup)
print(c.port)     # None       (fell through to __getattr__)
localhost
None
Guard against recursionInside __getattr__, don't access an undefined attribute of self or you'll recurse. Reading declared attributes is fine; unknown ones re-trigger the hook.
Exercise 3 · Write a typed, validating descriptorAdvanced

Context: Reusable field validation (non-negative, in range, correct type) is best expressed once as a descriptor and shared across classes.

Your task: Write a Typed descriptor that enforces an expected type on assignment and reuse it on two fields.

Requirements:

  • __set_name__ to capture the field name
  • __set__ raises TypeError if the value isn't the expected type
  • __get__ returns the stored value; demonstrate a good and a bad assignment

💡 Hint: Store the backing value under a mangled name like '_' + name.

Show solution
Solution
class Typed:
    def __init__(self, expected): self.expected = expected
    def __set_name__(self, owner, name): self.store = '_' + name
    def __get__(self, obj, objtype=None):
        return self if obj is None else getattr(obj, self.store)
    def __set__(self, obj, value):
        if not isinstance(value, self.expected):
            raise TypeError(f'expected {self.expected.__name__}')
        setattr(obj, self.store, value)

class User:
    name = Typed(str)
    age = Typed(int)
    def __init__(self, name, age): self.name, self.age = name, age

u = User('Ada', 36); print(u.name, u.age)   # Ada 36
try: User('Ada', 'old')
except TypeError as e: print('rejected:', e)
Ada 36
rejected: expected int
Exercise 4 · Trace a cooperative super() chainExpert

Context: Cooperative multiple inheritance (mixins that each do part of setup) only works if every class calls super() and you understand the MRO order.

Your task: Build a diamond of classes that each append to a shared list in __init__ via super(), then predict and verify the order from the MRO.

Requirements:

  • A Base and two mixins L, R, and a combined LR
  • Each __init__ calls super().__init__() then appends its name
  • Print LR.__mro__ and the resulting list; confirm they match

💡 Hint: The append order is the reverse of the call order, following the MRO.

Show solution
Solution
class Base:
    def __init__(self): self.log = ['Base']
class L(Base):
    def __init__(self): super().__init__(); self.log.append('L')
class R(Base):
    def __init__(self): super().__init__(); self.log.append('R')
class LR(L, R):
    def __init__(self): super().__init__(); self.log.append('LR')

print([c.__name__ for c in LR.__mro__])  # ['LR','L','R','Base','object']
print(LR().log)                           # ['Base','R','L','LR']
['LR', 'L', 'R', 'Base', 'object']
['Base', 'R', 'L', 'LR']

Calls go down the MRO (LR→L→R→Base); appends happen as the stack unwinds, so the list is the reverse: Base first, LR last.

Exercise 5 · A registry metaclass with validationProfessional

Context: Plugin systems and tool registries often want every subclass registered automatically and required methods enforced at definition time — failing fast, not at first call.

Your task: Write a metaclass that registers each concrete subclass and rejects any that omits a required run method.

Requirements:

  • Metaclass __new__ builds the class, registers it (skip the base), and checks for run
  • A missing run raises TypeError at class creation
  • Show a valid subclass registering and an invalid one failing

💡 Hint: Check 'run' in namespace (or on the built class) before returning it.

Show solution
Solution
class ToolMeta(type):
    registry = {}
    def __new__(mcls, name, bases, ns, **kw):
        cls = super().__new__(mcls, name, bases, ns)
        if bases:                                   # skip the base class
            if 'run' not in ns:
                raise TypeError(f'{name} must define run()')
            ToolMeta.registry[name.lower()] = cls
        return cls

class Tool(metaclass=ToolMeta): pass
class Search(Tool):
    def run(self): return 'searching'
print(sorted(ToolMeta.registry))       # ['search']
try:
    class Broken(Tool): pass           # no run()
except TypeError as e:
    print('rejected:', e)
['search']
rejected: Broken must define run()
Could this be __init_subclass__?Yes — registration + method-presence checks fit __init_subclass__ perfectly and read more simply. Use the metaclass form only if you also need to control the class object's construction.
Exercise 6 · Choose the object-model tool for a real featureIndustry scenario

Context: You own a framework where users subclass Agent to define tools. Product wants: (a) every tool auto-registered by a slug, (b) a timeout field that must be a positive number, (c) instances to be memory-light because a fleet spawns millions. Junior engineers keep reaching for metaclasses for all three.

Your task: Decide the right mechanism for each requirement and justify it; then sketch the minimal code.

Requirements:

  • Map each requirement to the lightest correct object-model feature
  • Justify why a metaclass is not needed for any of them
  • Give a short combined sketch

💡 Hint: Registration → subclass hook; validated field → descriptor; footprint → slots.

Show solution

(a) Auto-registration → __init_subclass__. It runs once per subclass, can read a slug= keyword from the class header, and needs no metaclass — the readable, conflict-free choice.

(b) Positive timeout → a data descriptor. A reusable Positive/Typed descriptor validates on every assignment and can't be shadowed by an instance attribute. @property is the same mechanism if only one class needs it.

(c) Memory-light instances → __slots__. Removing the per-instance __dict__ cuts footprint measurably across millions of objects and blocks accidental attributes.

None of these require controlling class construction, so a metaclass would add complexity and risk metaclass conflicts (e.g. with ABCMeta) for no benefit.

Sketch
class Positive:
    def __set_name__(self, o, n): self.s = '_' + n
    def __get__(self, o, t=None): return self if o is None else getattr(o, self.s)
    def __set__(self, o, v):
        if not (isinstance(v, (int, float)) and v > 0): raise ValueError('timeout>0')
        setattr(o, self.s, v)

class Agent:
    registry = {}
    def __init_subclass__(cls, /, slug=None, **kw):
        super().__init_subclass__(**kw)
        Agent.registry[slug or cls.__name__.lower()] = cls

class Search(Agent, slug='search'):
    __slots__ = ('timeout',)
    timeout = Positive()
    def __init__(self, timeout): self.timeout = timeout
slots + descriptor caveatA class-level data descriptor and a same-named slot collide. Here the validated field lives as the descriptor's backing store; if you slot the field itself, store the value under a different slotted name or add '__dict__' to slots.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in