Skip to main content
Technology & EngineeringPython Patterns225 lines

Metaclasses

Metaclass and descriptor patterns for advanced class customization in Python

Quick Summary18 lines
You are an expert in Python metaclasses and descriptors for advanced class creation and attribute management.

## Key Points

- **A metaclass** is a class whose instances are classes. The default metaclass is `type`.
- **`__init_subclass__`** (Python 3.6+) is a simpler hook that runs when a class is subclassed — often a better alternative to metaclasses.
- **Descriptors** implement `__get__`, `__set__`, and/or `__delete__` to customize attribute access.
- **Data descriptors** define `__set__` or `__delete__` and take priority over instance `__dict__`.
- **Non-data descriptors** define only `__get__` (e.g., functions/methods) and yield to instance `__dict__`.
- **`__set_name__`** (Python 3.6+) is called on descriptors when the owning class is created, providing the attribute name.
- **Prefer `__init_subclass__`** over metaclasses for registration, validation, and simple class customization — it is easier to understand and compose.
- Use descriptors to encapsulate reusable attribute logic (validation, lazy computation, type coercion).
- Always implement `__set_name__` on descriptors to automatically capture the attribute name.
- Keep metaclass logic minimal — they are hard to debug and compose (a class can only have one metaclass lineage).
- Document metaclass behavior explicitly; users of your framework need to know what magic is happening.
- **Metaclass conflicts** — if two base classes have different metaclasses that are not related by inheritance, Python raises `TypeError`.
skilldb get python-patterns-skills/MetaclassesFull skill: 225 lines
Paste into your CLAUDE.md or agent config

Metaclasses — Python Patterns

You are an expert in Python metaclasses and descriptors for advanced class creation and attribute management.

Overview

Metaclasses control how classes themselves are created — they are "classes of classes." Descriptors control how attribute access works on instances. Together, they are the machinery behind property, classmethod, staticmethod, ORMs, and many frameworks. Most Python developers rarely need to write metaclasses directly, but understanding them is essential for framework design and debugging.

Core Philosophy

Metaclasses and descriptors are Python's deepest customization hooks — they let you change what it means to define a class or access an attribute. This power is appropriate for framework authors who need to enforce contracts, register classes automatically, or transform class definitions at creation time. For application developers, the right question is almost always "can I do this with a simpler mechanism first?" — and the answer is usually yes.

The hierarchy of class customization tools should guide your design decisions. Start with plain classes and functions. If you need to customize subclass behavior, use __init_subclass__. If you need to control attribute access, use descriptors or property. If you need to enforce an interface, consider Protocol or ABC. Only reach for metaclasses when none of these simpler tools suffice — typically when you need to manipulate the class namespace before the class object is created, or when you need to intercept class creation for all subclasses in a hierarchy.

Descriptors deserve special attention because they are the hidden machinery behind so much of Python's behavior. Properties, methods, class methods, static methods, and __slots__ are all implemented as descriptors. When you understand the descriptor protocol — __get__, __set__, __delete__, and __set_name__ — you understand how Python's attribute access really works. This knowledge lets you build reusable, declarative field behaviors (validation, lazy loading, type coercion) that integrate seamlessly with Python's object model.

Core Concepts

  • A metaclass is a class whose instances are classes. The default metaclass is type.
  • __init_subclass__ (Python 3.6+) is a simpler hook that runs when a class is subclassed — often a better alternative to metaclasses.
  • Descriptors implement __get__, __set__, and/or __delete__ to customize attribute access.
  • Data descriptors define __set__ or __delete__ and take priority over instance __dict__.
  • Non-data descriptors define only __get__ (e.g., functions/methods) and yield to instance __dict__.
  • __set_name__ (Python 3.6+) is called on descriptors when the owning class is created, providing the attribute name.

Implementation Patterns

Basic metaclass

class RegistryMeta(type):
    registry: dict[str, type] = {}

    def __new__(mcs, name, bases, namespace):
        cls = super().__new__(mcs, name, bases, namespace)
        if name != "Base":
            mcs.registry[name] = cls
        return cls

class Base(metaclass=RegistryMeta):
    pass

class PluginA(Base):
    pass

class PluginB(Base):
    pass

print(RegistryMeta.registry)
# {"PluginA": <class 'PluginA'>, "PluginB": <class 'PluginB'>}

init_subclass (preferred alternative)

class Plugin:
    _registry: dict[str, type] = {}

    def __init_subclass__(cls, *, plugin_name: str | None = None, **kwargs):
        super().__init_subclass__(**kwargs)
        name = plugin_name or cls.__name__
        Plugin._registry[name] = cls

class AuthPlugin(Plugin, plugin_name="auth"):
    pass

class CachePlugin(Plugin, plugin_name="cache"):
    pass

print(Plugin._registry)
# {"auth": <class 'AuthPlugin'>, "cache": <class 'CachePlugin'>}

Enforcing interface with metaclass

class InterfaceMeta(type):
    def __new__(mcs, name, bases, namespace):
        cls = super().__new__(mcs, name, bases, namespace)
        if bases:  # skip the base class itself
            required = {"execute", "validate"}
            missing = required - set(namespace)
            if missing:
                raise TypeError(
                    f"{name} must implement: {', '.join(missing)}"
                )
        return cls

class Command(metaclass=InterfaceMeta):
    pass

class SaveCommand(Command):
    def execute(self): ...
    def validate(self): ...
    # Works fine

# class BadCommand(Command):
#     def execute(self): ...
#     # TypeError: BadCommand must implement: validate

Data descriptor with validation

class Validated:
    def __set_name__(self, owner, name):
        self.name = name
        self.storage_name = f"_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage_name, None)

    def __set__(self, obj, value):
        self.validate(value)
        setattr(obj, self.storage_name, value)

    def validate(self, value):
        raise NotImplementedError

class PositiveNumber(Validated):
    def validate(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f"{self.name} must be a number")
        if value <= 0:
            raise ValueError(f"{self.name} must be positive")

class BoundedString(Validated):
    def __init__(self, max_length: int = 100):
        self.max_length = max_length

    def validate(self, value):
        if not isinstance(value, str):
            raise TypeError(f"{self.name} must be a string")
        if len(value) > self.max_length:
            raise ValueError(f"{self.name} exceeds {self.max_length} chars")

class Product:
    name = BoundedString(max_length=50)
    price = PositiveNumber()
    quantity = PositiveNumber()

p = Product()
p.name = "Widget"
p.price = 9.99
p.quantity = 100

Non-data descriptor (method-like behavior)

class LazyProperty:
    """Descriptor that computes a value once and caches it on the instance."""

    def __init__(self, func):
        self.func = func
        self.__doc__ = func.__doc__

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

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        value = self.func(obj)
        # Cache on instance dict — bypasses descriptor on next access
        setattr(obj, self.name, value)
        return value

class DataPipeline:
    @LazyProperty
    def expensive_result(self):
        print("Computing...")
        return sum(range(10_000_000))

pipeline = DataPipeline()
print(pipeline.expensive_result)  # "Computing..." then result
print(pipeline.expensive_result)  # cached, no recomputation

class_getitem for generic-like syntax

class Wrapper:
    def __class_getitem__(cls, item):
        return type(f"Wrapper[{item.__name__}]", (cls,), {"item_type": item})

IntWrapper = Wrapper[int]
print(IntWrapper.item_type)  # <class 'int'>

Best Practices

  • Prefer __init_subclass__ over metaclasses for registration, validation, and simple class customization — it is easier to understand and compose.
  • Use descriptors to encapsulate reusable attribute logic (validation, lazy computation, type coercion).
  • Always implement __set_name__ on descriptors to automatically capture the attribute name.
  • Keep metaclass logic minimal — they are hard to debug and compose (a class can only have one metaclass lineage).
  • Document metaclass behavior explicitly; users of your framework need to know what magic is happening.

Common Pitfalls

  • Metaclass conflicts — if two base classes have different metaclasses that are not related by inheritance, Python raises TypeError.
  • Descriptor on the instance instead of the class — descriptors must be defined as class attributes, not in __init__.
  • Forgetting __set_name__ means the descriptor does not know its attribute name and cannot create unique storage per field.
  • Non-data descriptor vs instance dict — if a value is set in __dict__, a non-data descriptor is shadowed; data descriptors always win.
  • Overusing metaclasses when a class decorator, __init_subclass__, or a simple base class would suffice — metaclasses should be a last resort.

Anti-Patterns

  • Metaclass when init_subclass would suffice — writing a full metaclass with __new__ and __init__ to register subclasses or validate class attributes when __init_subclass__ does the same thing in three lines. Metaclasses add complexity, restrict inheritance, and confuse other developers who encounter them.

  • Magic attribute injection — a metaclass that silently adds methods, attributes, or modifies the class namespace in ways that are not visible in the source code. Users of the class see behavior that does not correspond to any code they can read, making debugging a forensic exercise.

  • Descriptor without set_name — defining a descriptor that requires manual name passing (name = ValidatedField("name")) instead of implementing __set_name__ to capture the attribute name automatically. This duplicates information and breaks when someone renames the attribute but forgets to update the string.

  • Mutable class-level state in metaclasses — storing a shared registry or configuration dict as a metaclass class variable without considering that all classes sharing the metaclass mutate the same dict. This creates action-at-a-distance bugs where defining a class in one module affects behavior in another.

  • Multiple inheritance with competing metaclasses — designing a class hierarchy where two base classes use different, unrelated metaclasses. Python raises TypeError because it cannot determine which metaclass to use. This is a fundamental design problem that requires restructuring, not a trick to work around.

Install this skill directly: skilldb add python-patterns-skills

Get CLI access →