__init_subclass__ and What Happens at class

The Course
•
September 19, 2026
•
9 min read

Yesterday’s models repeated themselves:

class Post(Model):
    table = "post"
    columns = ("title", "views")

columns restates what the class body already says, and forgetting a name there is a bug the library can’t see. Worse, yesterday’s cache fix relied on everyone remembering to key by class. Both are jobs for the moment the class is created.

What class actually does

A class statement is three steps: run the body as code, collect the resulting namespace, and hand it to the metaclass (type, normally) to build the class object. Two hooks let you join in without writing a metaclass at all.

  • __set_name__ runs on each object in the namespace that defines it, right after the class exists. That’s how a field learns its name.
  • __init_subclass__ runs on the parent class, once per subclass, with the new class as its argument.
The order of events Python
class Watcher:
    def __set_name__(self, owner, name):
        print(f"3. __set_name__ for {name}")


class Base:
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        print(f"4. __init_subclass__ sees {cls.__name__}, fields: "
              f"{[k for k, v in vars(cls).items() if isinstance(v, Watcher)]}")


print("1. before the class statement")


class Child(Base):
    print("2. inside the class body")
    field = Watcher()


print("5. after the class statement")

The body runs first, then __set_name__ for each descriptor, then __init_subclass__, and by that point the class is complete: the namespace is populated and the MRO is set. That makes it the right place to check a model over and to add whatever the library needs.

It's implicitly a classmethod

__init_subclass__ is turned into a classmethod for you, so it takes the new subclass as cls. Writing @classmethod yourself is harmless but unnecessary. It is not called for the class that defines it — only for its subclasses.

Class keyword arguments

A class statement can take keyword arguments, and they arrive in __init_subclass__. That’s how table="post" in the class header works:

class Model:
    def __init_subclass__(cls, table=None, **kwargs):
        super().__init_subclass__(**kwargs)
        cls.table = table or cls.__name__.lower()
class Post(Model, table="post"):
    ...

Passing **kwargs on to super() is not optional politeness. If a later class in the MRO also expects a keyword, swallowing it silently breaks it — and object.__init_subclass__ raises TypeError for anything left over, which is how a typo in a class keyword gets reported instead of ignored.

Keywords, defaults and typos Python
class Model:
    def __init_subclass__(cls, table=None, **kwargs):
        super().__init_subclass__(**kwargs)
        cls.table = table or cls.__name__.lower()


class Post(Model, table="post"):
    pass


class Comment(Model):        # no keyword: the default applies
    pass


print(Post.table, Comment.table)

try:
    class Broken(Model, tabel="post"):    # a typo
        pass
except TypeError as error:
    print("TypeError:", error)

The typo is caught at import time. The message — Broken.__init_subclass__() takes no keyword arguments — comes from object.__init_subclass__, the last link in the chain, and doesn’t name tabel or point at the header; it is nonetheless an error rather than silence. A library that swallowed **kwargs instead of passing them up would write to a table called broken and leave someone debugging their data instead of their spelling.

Collecting fields, including inherited ones

Walking vars(cls) finds the fields written in this class body, but a model that inherits from another model should keep its parent’s fields too. The MRO, reversed, gives base classes first, so a subclass can override a field by redefining the name:

        cls.fields = {
            name: value
            for klass in reversed(cls.__mro__)
            for name, value in vars(klass).items()
            if isinstance(value, Field)
        }
Inheriting and overriding fields Python
class Field:
    def __init__(self, label):
        self.label = label

    def __set_name__(self, owner, name):
        self.name = name

    def __repr__(self):
        return f"Field({self.label!r})"


class Model:
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        cls.fields = {
            name: value
            for klass in reversed(cls.__mro__)
            for name, value in vars(klass).items()
            if isinstance(value, Field)
        }


class Content(Model):
    title = Field("base title")
    created = Field("created")


class Post(Content):
    title = Field("overridden title")     # same name, new field
    views = Field("views")


print("Content:", Content.fields)
print("Post:   ", Post.fields)
print("order:  ", tuple(Post.fields))

Post.fields has three entries: the overridden title, the inherited created and the new views. Order follows insertion, and the MRO is walked from object downwards, so the order is the one the base class established — title is still first, because Content introduced it — while the value for an overridden name comes from the most derived class.

The two jobs that fix yesterday’s bugs

        cls._instances = {}              # its own cache, not the base class's
        Model.registry[cls.table] = cls  # one registry, on purpose

The first line is the real fix for yesterday’s shared cache: every model gets a fresh dictionary as it is defined, so Post._instances is User._instances is False and keys can go back to being plain ids. The library guarantees it instead of asking each model’s author to remember.

The second line is a deliberately shared dictionary, on Model itself, so Model.registry["post"] finds the class. Same mechanism, opposite decision — which is the point. Shared mutable class state is a bug when it’s accidental and a feature when it’s the thing you meant.

A registry keeps classes alive

Model.registry holds a strong reference to every model ever defined. For classes that’s usually fine — they live as long as their module — but it does mean a test that defines a model inside a function leaks it into the registry, and a second definition with the same table name silently replaces the first. Worth a documented note, and a check if you want one.

Going further: why not a metaclass?

Everything above can be done in a metaclass, and for twenty years it was. It is now the wrong default, for one practical reason: metaclasses conflict. A class can have only one, and its metaclass must be a subclass of the metaclasses of all its bases. Combine your ORM model with any other library that uses one and the user gets TypeError: metaclass conflict, which they cannot fix from their side.

__init_subclass__ is ordinary inheritance and composes without drama.

What still needs a metaclass is rare and specific: changing the class namespace before the body runs (__prepare__), controlling isinstance with __instancecheck__, or altering how the class object itself is constructed. Field collection, validation, registration and defaults are not on that list.

Exercise

A model base that catches mistakes early

Complete __init_subclass__. Beyond collecting fields, it enforces two rules of this library — each caught when the model is defined, not when it’s used.

import unittest


class Field:
    def __set_name__(self, owner, name):
        self.name = name


class Model:
    registry = {}

    def __init_subclass__(cls, table=None, **kwargs):
        """TODO:
        - pass **kwargs to super().__init_subclass__
        - set cls.table from the keyword, defaulting to the lowercased class name
        - collect cls.fields from the whole MRO, base classes first
        - give cls its own empty _instances dict
        - refuse a model with no fields (ValueError)
        - refuse a table name already in Model.registry (ValueError), then register
        """
        raise NotImplementedError("__init_subclass__")


class Models(unittest.TestCase):
    def setUp(self):
        Model.registry.clear()

    def define_post(self, table="post"):
        class Post(Model, table=table):
            title = Field()
            views = Field()
        return Post

    def test_fields_and_table(self):
        Post = self.define_post()
        self.assertEqual(tuple(Post.fields), ("title", "views"))
        self.assertEqual(Post.table, "post")

    def test_the_table_defaults_to_the_class_name(self):
        class Comment(Model):
            body = Field()
        self.assertEqual(Comment.table, "comment")

    def test_each_model_has_its_own_cache(self):
        first = self.define_post("post")
        second = self.define_post("other")
        self.assertEqual(first._instances, {})
        self.assertIsNot(first._instances, second._instances)
        self.assertIsNot(first._instances, Model.__dict__.get("_instances"))

    def test_fields_are_inherited(self):
        Post = self.define_post()
        class Draft(Post, table="draft"):
            note = Field()
        self.assertEqual(tuple(Draft.fields), ("title", "views", "note"))

    def test_a_model_without_fields_is_refused(self):
        with self.assertRaises(ValueError):
            class Empty(Model, table="empty"):
                pass

    def test_two_models_may_not_claim_one_table(self):
        self.define_post("post")
        with self.assertRaises(ValueError):
            self.define_post("post")

    def test_registered_by_table_name(self):
        Post = self.define_post()
        self.assertIs(Model.registry["post"], Post)


unittest.TextTestRunner(verbosity=2).run(
    unittest.defaultTestLoader.loadTestsFromTestCase(Models)
)
Hint 1: the idea

__init_subclass__ runs once for each subclass, the moment its class statement finishes — which is why it’s where a library checks a model’s definition. Everything it sets goes on cls, the new subclass. “Fields from the whole MRO, base classes first” means walking cls.__mro__ from the far end, looking for Field objects in each class’s own namespace.

Hint 2: a first step

Do the short lines first — super().__init_subclass__(**kwargs), cls.table, cls._instances = {}, and registering — and get the table and cache tests passing. Then the fields: vars(klass) is one class’s own namespace, and reversed(cls.__mro__) visits the base classes first.

Show one solution
    def __init_subclass__(cls, table=None, **kwargs):
        super().__init_subclass__(**kwargs)
        cls.table = table or cls.__name__.lower()
        cls.fields = {
            name: value
            for klass in reversed(cls.__mro__)
            for name, value in vars(klass).items()
            if isinstance(value, Field)
        }
        cls._instances = {}
        if not cls.fields:
            raise ValueError(f"{cls.__name__} has no fields")
        if cls.table in Model.registry:
            taken = Model.registry[cls.table].__name__
            raise ValueError(f"table {cls.table!r} is already used by {taken}")
        Model.registry[cls.table] = cls

The order matters in two places. cls.fields has to be built before the “no fields” check, obviously — but also, both checks come after the assignments, so a model that fails one is still fully formed when the exception is raised. That keeps the traceback readable in a test like the one above, where the class object is examined after the failure.

The duplicate-table check reads the registry before writing to it, which is what makes it a check rather than a race with itself.

Raising here means a broken model can’t be imported. That’s the harshest feedback a library can give, and the right one for a mistake that would otherwise corrupt data.

What you learned

  • __set_name__ then __init_subclass__ run as a class is created, and both see a finished namespace.
  • Class keyword arguments arrive in __init_subclass__; passing **kwargs up is what turns a typo into a TypeError.
  • Walking the reversed MRO collects inherited fields and lets a subclass override one.
  • Per-subclass state is created here, which is how the library guarantees an invariant instead of documenting it.
  • Metaclasses conflict; __init_subclass__ is ordinary inheritance and should be your default.
Last updated on September 19, 2026

Was this article helpful?

Your response is saved on this device.