Create your own
Lesson illustration

Initializing and Validating Subclasses with `__init_subclass__`

Welcome back. In the previous lesson, you made JobBatch participate in Python’s sequence protocol by implementing a small core and inheriting the rest of the behavior from Sequence. That was controlled behavior of instances. This lesson shifts to controlled behavior when subclasses are defined.

A job-processing service will eventually need several handler types, each with stable configuration such as a job kind and a timeout. Rather than relying on convention (“remember to set these attributes”) or reaching immediately for a metaclass, you can make that configuration part of the subclass declaration itself and validate it at definition time.

By the end of this lesson, you will use __init_subclass__ to require, validate, initialize, and register configuration for job-handler subclasses—while preserving compatibility with cooperative multiple inheritance.


Configuration belongs to the class, not its instances

Consider two handler implementations:

class SendEmailHandler:
    kind = "send_email"
    timeout_seconds = 30


class GenerateReportHandler:
    kind = "generate_report"
    timeout_seconds = 120

These values describe the handler type. Every SendEmailHandler instance has the same supported job kind and timeout policy. They should therefore be class attributes, not parameters repeated in every instance constructor.

The weak version of this design relies on convention:

class JobHandler:
    pass


class SendEmailHandler(JobHandler):
    kind = "send_email"
    timeout_seconds = 30

Nothing prevents an incomplete or invalid subclass:

class BrokenHandler(JobHandler):
    kind = "Send Email"  # Wrong format, and timeout is missing.

The error might surface much later, perhaps when a worker tries to route a job. That is a poor failure point: the class is invalid the instant it is defined.

__init_subclass__ lets the base class intervene at that moment.

MechanismWhen it runsReceivesTypical responsibility
__init__Each time an instance is createdThe new instance, selfInitialize instance state
__init_subclass__Each time a subclass is definedThe new subclass, clsValidate and initialize subclass configuration
Metaclass methodsDuring class constructionClass name, bases, namespace, and moreCustomize the class-creation machinery itself

The key distinction is that __init_subclass__ works through ordinary inheritance. You define it on a base class, and Python calls it whenever a future subclass of that base is created.


Read the language-level contract first

The data model documentation establishes two details that determine a robust implementation:

  1. __init_subclass__ receives the newly created subclass as cls.
  2. Keyword arguments in a class header are passed to the parent hook, so each class in a cooperative hierarchy must consume only its own options and forward the remainder.

3. Data model — Python 3.14.2 documentation

Read the official Python documentation to establish precisely when __init_subclass__ is called, how class-header keywords reach it, and where the hook sits in the broader class-creation sequence.

In Section 3.3.3, “Customizing class creation,” read from the explanation of class-header options through keyword forwarding. Focus on why cls is the new subclass and why forwarding unused keywords matters. Then read Section 3.3.3.6, “Creating the class object.” Start at class creation, then continue through the numbered list. Notice that __init_subclass__ runs after the class object exists, and after any __set_name__ calls, but before class decorators and before the final class binding.

For a declaration such as:

class SendEmailHandler(
    JobHandler,
    kind="send_email",
    timeout_seconds=30,
):
    ...

the important conceptual sequence is:

  1. Python executes the class body and constructs a class object.
  2. Python invokes the relevant parent __init_subclass__ hook with cls set to SendEmailHandler.
  3. The hook validates the supplied configuration and initializes class attributes.
  4. If validation succeeds, the completed class is bound to the name SendEmailHandler; if it fails, the class statement raises immediately.

This is definition-time validation. In a typical application, it happens while importing the module containing the handler class.


Build a validated handler base class

Create app/job_handlers.py. The base class below enforces three policies:

  • every concrete handler subclass must declare a kind;
  • kind must be a stable, lowercase, whitespace-free identifier for routing;
  • timeout_seconds must be a positive integer, and bool is rejected even though bool is technically a subclass of int.

It also maintains a registry. That is useful because a worker that receives a job with kind="send_email" must be able to locate the corresponding handler class.

from __future__ import annotations

from abc import ABC, abstractmethod
from collections.abc import Mapping
from typing import ClassVar


class JobHandler(ABC):
    """Base class for handlers registered by a stable job kind."""

    _registry: ClassVar[dict[str, type[JobHandler]]] = {}

    kind: ClassVar[str]
    timeout_seconds: ClassVar[int]

    def __init_subclass__(
        cls,
        /,
        *,
        kind: str,
        timeout_seconds: int = 60,
        **kwargs: object,
    ) -> None:
        if not isinstance(kind, str):
            raise TypeError("kind must be a string")

        if (
            not kind
            or kind != kind.strip()
            or kind != kind.lower()
            or any(character.isspace() for character in kind)
        ):
            raise ValueError(
                "kind must be non-empty, lowercase, and contain no whitespace"
            )

        if isinstance(timeout_seconds, bool) or not isinstance(
            timeout_seconds,
            int,
        ):
            raise TypeError("timeout_seconds must be an integer")

        if timeout_seconds <= 0:
            raise ValueError("timeout_seconds must be positive")

        if kind in JobHandler._registry:
            existing = JobHandler._registry[kind]
            raise ValueError(
                f"kind {kind!r} is already registered by {existing.__name__}"
            )

        # Forward options owned by other bases in a multiple-inheritance hierarchy.
        super().__init_subclass__(**kwargs)

        # Initialize only after all local validation and parent hooks succeed.
        cls.kind = kind
        cls.timeout_seconds = timeout_seconds
        JobHandler._registry[kind] = cls

    @classmethod
    def resolve(cls, kind: str) -> type[JobHandler]:
        """Return the registered handler type for a job kind."""
        try:
            return cls._registry[kind]
        except KeyError as error:
            raise LookupError(f"No handler is registered for {kind!r}") from error

    @classmethod
    def registered_handlers(cls) -> Mapping[str, type[JobHandler]]:
        """Expose the registry for inspection without promising mutation."""
        return cls._registry

    @abstractmethod
    async def handle(self, payload: Mapping[str, object]) -> None:
        """Process one job payload."""

Now define handlers in the same file or, more realistically, in modules imported during application startup:

class SendEmailHandler(
    JobHandler,
    kind="send_email",
    timeout_seconds=30,
):
    async def handle(self, payload: Mapping[str, object]) -> None:
        recipient = payload["recipient"]
        subject = payload["subject"]

        print(f"Sending {subject!r} to {recipient!r}")


class GenerateReportHandler(
    JobHandler,
    kind="generate_report",
    timeout_seconds=120,
):
    async def handle(self, payload: Mapping[str, object]) -> None:
        report_id = payload["report_id"]

        print(f"Generating report {report_id!r}")

The class header is now a compact declarative contract:

class SendEmailHandler(
    JobHandler,
    kind="send_email",
    timeout_seconds=30,
):

It says this class is a job handler, handles send_email jobs, and has a 30-second timeout. The base class—not each individual handler author—owns the rules governing that declaration.

You can inspect the result:

assert SendEmailHandler.kind == "send_email"
assert SendEmailHandler.timeout_seconds == 30

handler_type = JobHandler.resolve("send_email")

assert handler_type is SendEmailHandler

Notice that no SendEmailHandler instance had to be created. The registry contains classes, because selecting a handler type is a routing decision made before any particular handler instance is needed.

Why the slash and star in the signature?

This signature is deliberate:

def __init_subclass__(
    cls,
    /,
    *,
    kind: str,
    timeout_seconds: int = 60,
    **kwargs: object,
) -> None:
  • / means cls is positional-only. This follows the built-in hook’s convention.
  • * means configuration such as kind and timeout_seconds must be named.
  • **kwargs accepts options intended for another parent class.

This prevents ambiguous positional configuration such as:

# Do not design an API that permits this.
class SendEmailHandler(JobHandler, "send_email", 30):
    ...

Class configuration should be self-documenting.


Fail where the invalid class is written

Try each invalid class definition in a Python shell or a focused test. Python raises while executing the class statement.

A missing required configuration argument fails before the hook body can run:

class MissingKind(JobHandler):
    async def handle(self, payload: Mapping[str, object]) -> None:
        return None

This raises a TypeError equivalent to:

JobHandler.__init_subclass__() missing 1 required keyword-only argument: 'kind'

A malformed kind reaches your explicit validation:

class BadKind(
    JobHandler,
    kind="Send Email",
):
    async def handle(self, payload: Mapping[str, object]) -> None:
        return None

This raises:

ValueError: kind must be non-empty, lowercase, and contain no whitespace

A duplicate kind is rejected as well:

class AnotherEmailHandler(
    JobHandler,
    kind="send_email",
):
    async def handle(self, payload: Mapping[str, object]) -> None:
        return None

This is especially valuable for durable jobs. If two implementations claim the same routing key, the worker cannot select one unambiguously. Failing at import time is safer than accepting an accidental last-definition-wins policy.


Test the class-definition contract

Place focused tests in tests/test_job_handlers.py. These tests should verify public effects of subclass creation: initialized attributes, registration, and early rejection of invalid declarations.

from collections.abc import Mapping

import pytest

from app.job_handlers import JobHandler, SendEmailHandler


def test_handler_subclass_receives_its_validated_configuration() -> None:
    assert SendEmailHandler.kind == "send_email"
    assert SendEmailHandler.timeout_seconds == 30

    assert JobHandler.resolve("send_email") is SendEmailHandler

Next, verify that invalid configuration cannot create a usable class:

def test_handler_kind_must_be_lowercase_and_whitespace_free() -> None:
    with pytest.raises(
        ValueError,
        match="lowercase",
    ):

        class InvalidHandler(
            JobHandler,
            kind="Send Email",
        ):
            async def handle(
                self,
                payload: Mapping[str, object],
            ) -> None:
                return None

Finally, test duplicate registration. This test deliberately conflicts with the already-imported SendEmailHandler:

def test_handler_kind_must_be_unique() -> None:
    with pytest.raises(
        ValueError,
        match="already registered",
    ):

        class DuplicateEmailHandler(
            JobHandler,
            kind="send_email",
        ):
            async def handle(
                self,
                payload: Mapping[str, object],
            ) -> None:
                return None

A useful implementation habit here is to define a new test-local class only when testing definition-time behavior. The class statement itself is the action under test.


Forward unknown options with super()

Calling super().__init_subclass__(**kwargs) is not ceremonial. It makes your base class composable with other bases that also use definition-time configuration.

Suppose a mixin requires an audit stream:

from typing import ClassVar


class Audited:
    audit_stream: ClassVar[str]

    def __init_subclass__(
        cls,
        /,
        *,
        audit_stream: str,
        **kwargs: object,
    ) -> None:
        if not audit_stream:
            raise ValueError("audit_stream must be non-empty")

        super().__init_subclass__(**kwargs)
        cls.audit_stream = audit_stream

A handler can now combine the two class-level contracts:

class AuditedReportHandler(
    Audited,
    JobHandler,
    kind="generate_report",
    timeout_seconds=120,
    audit_stream="jobs",
):
    async def handle(self, payload: Mapping[str, object]) -> None:
        report_id = payload["report_id"]

        print(f"Generating audited report {report_id!r}")

Here, Audited.__init_subclass__ receives both configuration keywords initially. It consumes audit_stream, then forwards kind and timeout_seconds. The next hook in the method resolution order, JobHandler.__init_subclass__, consumes those settings and forwards an empty keyword dictionary to the remaining bases.

The requirements for cooperative hooks are simple but strict:

  1. Accept your own explicitly named configuration options.
  2. Accept **kwargs for options that may belong to other bases.
  3. Remove your options by declaring them as parameters.
  4. Call super().__init_subclass__(**kwargs) exactly once.

If a misspelled option survives every hook, it eventually reaches object.__init_subclass__, which accepts no keyword arguments. Python then raises TypeError. That behavior is helpful: unknown configuration does not silently disappear.

Do not omit super() merely because your current class hierarchy has one parent. A base class can later become part of a multiple-inheritance hierarchy, and a missing super() breaks hooks that appear later in the method resolution order.


Why not use a metaclass?

__init_subclass__ is the right tool when the job is:

  • requiring or validating subclass options;
  • assigning derived class attributes;
  • maintaining a subclass registry;
  • rejecting invalid subclass declarations.

It is not a replacement for every metaclass use case. A metaclass participates directly in constructing the class object and can control the namespace, alter class construction, or enforce rules that need access to the raw class body before ordinary subclass initialization.

For configuration and registration, however, a metaclass is usually unnecessary machinery and can introduce conflicts when a class must combine frameworks or mixins with different metaclasses.

PEP 487 – Simpler customisation of class creation | peps.python.org

Read the design rationale behind __init_subclass__. It explains why ordinary inheritance hooks cover common definition-time tasks while avoiding many metaclass-composition problems.

In the “Proposal” section, read the motivation. Then, in “Key Benefits,” read the subsection “Easier inheritance of definition time behaviour,” beginning at the design rationale. Relate the discussion to the handler registry: it is initialization after class creation, not customization of the construction machinery itself.

A class decorator could also register one particular handler:

@register_handler
class SendEmailHandler:
    ...

But a decorator must be applied explicitly to every target class. __init_subclass__ establishes a rule for the entire future inheritance tree: if it is a JobHandler, it must provide valid handler configuration.


Common near-misses

Putting configuration in __init__

class JobHandler:
    def __init__(self, kind: str, timeout_seconds: int) -> None:
        ...

This repeats invariant configuration for every instance and allows different instances of the same handler class to disagree about what kind they handle. Routing metadata belongs to the class.

Forgetting **kwargs

def __init_subclass__(cls, *, kind: str) -> None:
    ...

This works only until another base needs its own class keyword. Prefer a cooperative signature from the beginning.

Forgetting super()

def __init_subclass__(cls, *, kind: str, **kwargs: object) -> None:
    cls.kind = kind
    # Missing super call.

This silently prevents later parent hooks from seeing their configuration. It is a latent multiple-inheritance bug.

Treating class-header keywords as instance arguments

class SendEmailHandler(JobHandler, kind="send_email"):
    ...

The kind argument is consumed when Python creates the class. It is not saved and passed automatically to:

SendEmailHandler()

Your hook must explicitly store it, as cls.kind = kind, if instances or other code should access it later.


Takeaways

  • __init_subclass__ runs whenever a new subclass is defined, with cls bound to that new class.
  • Class-header keywords provide a clear declarative configuration interface for subclasses.
  • A base class can validate configuration, initialize class attributes, and register subclasses before the invalid type can be used.
  • Use explicit keyword-only parameters for settings your base owns, and forward all remaining options through super().__init_subclass__(**kwargs).
  • This cooperative pattern makes definition-time behavior work with mixins and avoids many reasons to introduce a metaclass.
  • Use a metaclass only when you truly need to customize class construction itself, not merely validate or initialize a finished subclass.

Next, you will zoom in on the full lifecycle of a class statement: namespace preparation, class-body execution, metaclass selection, and class-object creation.

Can't find a good explanation? Sign up and we'll make it for you

Sign up