Metaclasses & Descriptors

Master type() as metaclass, __init_subclass__, the descriptor protocol, and practical metaprogramming patterns.

Advanced 40 min read 🐍 Python

type() — The Class of Classes

In Python, everything is an object — including classes themselves. When you write class Dog: ..., Python creates a class object. But what creates that class object? The answer is type. Just as Dog creates dog instances, type creates class instances. This makes type a metaclass — a class whose instances are themselves classes.

# Every object has a type
print(type(42))          # 
print(type("hello"))     # 
print(type([1, 2, 3]))   # 

# Classes are objects too — their type is 'type'
class Dog:
    pass

print(type(Dog))         # 
print(type(int))         # 
print(type(type))        #  — type is its own metaclass!
Output
<class 'int'>
<class 'str'>
<class 'list'>
<class 'type'>
<class 'type'>
<class 'type'>

Creating Classes Dynamically with type()

Since type creates classes, you can create them at runtime without the class keyword. This is rarely needed in application code, but understanding it is key to understanding metaclasses:

# Normal class definition
class Dog:
    species = "Canis familiaris"
    def bark(self):
        return "Woof!"

# Equivalent using type() directly
Dog = type('Dog', (), {
    'species': 'Canis familiaris',
    'bark': lambda self: 'Woof!',
})

rex = Dog()
print(rex.bark())
print(rex.species)
Output
Woof!
Canis familiaris

type(name, bases, namespace) takes three arguments: the class name, a tuple of base classes, and a dictionary of attributes/methods. This is exactly what Python does internally when it encounters a class statement.

Custom Metaclasses

A custom metaclass lets you intercept and modify class creation. You subclass type and override __new__ (which creates the class) or __init__ (which initializes it):

class ValidatedMeta(type):
    """Metaclass that ensures all public methods have docstrings."""
    def __new__(mcs, name, bases, namespace):
        for key, value in namespace.items():
            if callable(value) and not key.startswith('_'):
                if not getattr(value, '__doc__', None):
                    raise TypeError(
                        f"{name}.{key}() is missing a docstring"
                    )
        return super().__new__(mcs, name, bases, namespace)

class APIEndpoint(metaclass=ValidatedMeta):
    def get(self):
        """Handle GET requests."""
        return "OK"

    def post(self):
        """Handle POST requests."""
        return "Created"

print("APIEndpoint created successfully!")

# This would FAIL:
# class BadEndpoint(metaclass=ValidatedMeta):
#     def get(self):       # No docstring!
#         return "OK"
# TypeError: BadEndpoint.get() is missing a docstring
Output
APIEndpoint created successfully!

When to Use Metaclasses

Metaclasses are powerful but rarely needed. Use them for: enforcing coding standards across many classes, automatic registration (plugin systems), ORM magic (SQLAlchemy uses them), and API frameworks. If you're not sure you need a metaclass, you probably don't.

__init_subclass__ — The Simpler Alternative

Python 3.6 added __init_subclass__ as a simpler way to customize class creation without writing a full metaclass. It's called automatically whenever a class is subclassed:

class Plugin:
    """Base class that auto-registers subclasses."""
    _registry = {}

    def __init_subclass__(cls, name=None, **kwargs):
        super().__init_subclass__(**kwargs)
        plugin_name = name or cls.__name__.lower()
        Plugin._registry[plugin_name] = cls
        print(f"  Registered plugin: {plugin_name}")

class JSONPlugin(Plugin, name="json"):
    def process(self, data):
        import json
        return json.dumps(data)

class XMLPlugin(Plugin, name="xml"):
    def process(self, data):
        return f"{data}"

class CSVPlugin(Plugin):  # No name — uses class name
    def process(self, data):
        return str(data)

print(f"\nRegistry: {list(Plugin._registry.keys())}")

# Create plugin by name
plugin = Plugin._registry["json"]()
print(plugin.process({"key": "value"}))
Output
  Registered plugin: json
  Registered plugin: xml
  Registered plugin: csvplugin

Registry: ['json', 'xml', 'csvplugin']
{"key": "value"}
Key Takeaway: Use __init_subclass__ instead of metaclasses when possible. It covers 90% of use cases (registration, validation, modification) with much simpler code.

The Descriptor Protocol

Descriptors are the mechanism behind @property, @classmethod, @staticmethod, and how attribute access works in Python. A descriptor is any object that implements __get__, __set__, or __delete__:

class Validated:
    """A descriptor that validates values on assignment."""
    def __init__(self, min_value=None, max_value=None):
        self.min_value = min_value
        self.max_value = max_value

    def __set_name__(self, owner, name):
        """Called when the descriptor is assigned to a class attribute."""
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self  # Accessed from class, not instance
        return obj.__dict__.get(self.name)

    def __set__(self, obj, value):
        if self.min_value is not None and value < self.min_value:
            raise ValueError(f"{self.name} must be >= {self.min_value}, got {value}")
        if self.max_value is not None and value > self.max_value:
            raise ValueError(f"{self.name} must be <= {self.max_value}, got {value}")
        obj.__dict__[self.name] = value

class Product:
    price = Validated(min_value=0)
    quantity = Validated(min_value=0, max_value=1000)

p = Product()
p.price = 29.99
p.quantity = 5
print(f"Product: ${p.price}, qty: {p.quantity}")

try:
    p.price = -10  # ValueError!
except ValueError as e:
    print(f"Error: {e}")

try:
    p.quantity = 5000  # ValueError!
except ValueError as e:
    print(f"Error: {e}")
Output
Product: $29.99, qty: 5
Error: price must be >= 0, got -10
Error: quantity must be <= 1000, got 5000

__set_name__ (Python 3.6+) is called automatically when the descriptor is assigned to a class attribute, passing the attribute name. This eliminates the need to repeat the name manually.

@property is a Descriptor

The @property decorator you learned in the OOP tutorial is actually implemented as a descriptor:

# These two are equivalent:

# Using @property
class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

# Using property() descriptor directly
class Circle:
    def __init__(self, radius):
        self._radius = radius

    def _get_radius(self):
        return self._radius

    radius = property(_get_radius)  # Descriptor!
🔍 Deep Dive: Data vs Non-Data Descriptors

A data descriptor implements both __get__ and __set__ (like property). A non-data descriptor implements only __get__ (like regular methods and classmethod). The difference matters for attribute lookup order: data descriptors take priority over instance __dict__, while non-data descriptors don't. This is why you can override methods on instances but not properties.

Practical Uses

PatternMechanismExample
Plugin registration__init_subclass__Auto-register handlers by subclassing
Attribute validationDescriptorsType/range checking on assignment
ORM fieldsDescriptors + metaclassSQLAlchemy Column()
API serializationMetaclassPydantic BaseModel
Abstract methodsMetaclass (ABCMeta)@abstractmethod
SingletonMetaclass with __call__One instance per class

⚠️ Common Mistake: Overusing Metaclasses

Wrong: Using a metaclass when a simpler solution exists.

Why: Metaclasses add complexity, are hard to debug, and confuse other developers. Tim Peters (author of The Zen of Python) said: "Metaclasses are deeper magic than 99% of users should ever worry about."

Instead: Try these alternatives first: __init_subclass__, class decorators, descriptors, or regular inheritance. Only reach for metaclasses when nothing else works.