Metaclasses & Descriptors

Deep dive into Python's object model and metaprogramming

Metaclasses and descriptors form the foundation of Python's runtime behavior and are heavily used by frameworks such as Django, SQLAlchemy, Pydantic, and dataclasses. The goal is not memorization, but building a correct mental model of how Python creates classes and resolves attribute access.

Python's Object Model

In Python, everything is an object, including classes themselves. This creates a hierarchy: instances → classes → metaclasses.

# Everything is an object
x = 10
print(x.__class__)        # <class 'int'>
print(int.__class__)      # <class 'type'>
print(type.__class__)     # <class 'type'>

# type is its own metaclass!
print(type(type))         # <class 'type'>
Mental model: instance → class → metaclass (usually type)

Interactive Demonstrations

Temperature Descriptor

Descriptors control attribute access. This example shows a descriptor that converts between Celsius and Kelvin, with validation.

class Celsius:
    """Descriptor that stores temperature in Kelvin"""
    
    def __set_name__(self, owner, name):
        self.private_name = f'_{name}'
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        kelvin = getattr(obj, self.private_name)
        return kelvin - 273.15  # Convert to Celsius
    
    def __set__(self, obj, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero!")
        kelvin = value + 273.15
        setattr(obj, self.private_name, kelvin)

class Temperature:
    celsius = Celsius()  # Descriptor instance

# Example Usage:
temp = Temperature()

# Set the temperature in Celsius (internally stored as Kelvin)
temp.celsius = 25.0
print(f"Temperature (C): {temp.celsius}") # Output: Temperature (C): 25.0

# Attempt to set a value below absolute zero
try:
    temp.celsius = -300.0
except ValueError as e:
    print(e) # Output: Temperature below absolute zero!

# The original value remains unchanged after the error
print(f"Current Temperature (C): {temp.celsius}") # Output: Current Temperature (C): 25.0

# Accessing the underlying private attribute (in Kelvin)
# print(temp._celsius) # This would output 298.15 (25 + 273.15)

The Descriptor Protocol

A descriptor is any object that implements one or more of these methods:

__get__
__get__(self, instance, owner)

Called when the attribute is accessed

__set__
__set__(self, instance, value)

Called when the attribute is assigned

__delete__
__delete__(self, instance)

Called when the attribute is deleted

Data vs Non-Data Descriptors:
  • Data descriptors define both __get__ and __set__(or __delete__). They take priority over instance attributes.
  • Non-data descriptors only define __get__. Instance attributes can override them (this is how methods work!).

__set_name__: Descriptor Initialization

class Field:
    def __set_name__(self, owner, name):
        # Called automatically when descriptor is assigned to class
        self.private_name = f"_{name}"
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.private_name)
    
    def __set__(self, obj, value):
        setattr(obj, self.private_name, value)

class Example:
    # __set_name__ is called with owner=Example, name='field'
    field = Field()
Why it matters: Introduced in Python 3.6, __set_name__ eliminates boilerplate and enables reusable descriptors without manual name tracking.
Storage Strategy: Most descriptors store data in the instance's __dict__ (using a private name like _age). Older tutorials might suggest WeakKeyDictionary to avoid naming conflicts, but __set_name__ makes that largely obsolete.

Metaclasses: Classes for Classes

A metaclass is a class that creates other classes. If classes define behavior for instances, metaclasses define behavior for classes themselves.

class Meta(type):
    def __new__(cls, name, bases, namespace):
        # Called when a class using this metaclass is created
        namespace['created_by_meta'] = True
        namespace['class_name'] = name
        return super().__new__(cls, name, bases, namespace)
    
    def __init__(cls, name, bases, namespace):
        # Called after the class is created
        super().__init__(name, bases, namespace)
        print(f"Class {name} initialized")

class Example(metaclass=Meta):
    pass

print(Example.created_by_meta)  # True
print(Example.class_name)       # 'Example'
Warning: Metaclasses are powerful but hard to reason about, use sparingly. Consider __init_subclass__ (Python 3.6+) as a simpler alternative for many use cases.
Modern Alternative: __init_subclass__

Instead of a metaclass for registration, use __init_subclass__. It's a standard method called when a class is subclassed.

The Old Way (Metaclass)
class RegistryMeta(type):
    def __init__(cls, name, bases, ns):
        super().__init__(name, bases, ns)
        if name != 'Base':
            register(cls)

class Base(metaclass=RegistryMeta):
    pass
The Modern Way (__init_subclass__)
class Base:
    @classmethod
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        register(cls)

# No metaclass conflict!
class User(Base): 
    pass
Bonus: Reusable Factories with core-mixins

To avoid rewriting registration logic in every project, consider using thecore-mixins library.

The core_mixins.interfaces.IFactory interface provides a battle-tested implementation for:

  • Automatic registration of concrete implementations
  • Runtime discovery of available plugins
  • Instantiation by string reference
  • Separated registries for different factory types

Real-World Usage

Descriptors Used In:
  • @property - Python's built-in decorator
  • Methods - Functions bound to instances
  • Django Models - Field definitions
  • SQLAlchemy - Column declarations
Metaclasses Used In:
  • ABCMeta - Abstract base classes
  • Enum - Enumeration types
  • Django ORM - Model registration
  • Singletons - Class-level control
Guideline: If decorators, composition, or __init_subclass__ can solve your problem, prefer them over metaclasses.

Key Takeaways

  • Descriptors control attribute access on instances from the class level
  • Metaclasses control class creation and can modify classes as they're defined
  • Most Python "magic" builds on these primitives (@property, methods, ORMs, etc.)
  • Power comes with complexity, use intentionally and prefer simpler alternatives when possible
  • __set_name__ and __init_subclass__ are modern, simpler alternatives to consider first
What's Next?

You've learned metaprogramming! Now let's explore memory management and optimization to write high-performance Python code.

  • Garbage Collection - Understand Python's memory management
  • Memory Profiling - Identify and fix memory leaks
  • Optimization Techniques - Use __slots__, weak references, and more