Python Metaprogramming: Metaclasses, Descriptors, and slots
Metaprogramming is a program's ability to inspect, modify, or generate code at runtime. Python exposes its object model in an extraordinarily transparent way, enabling advanced techniques like metaclasses, descriptors, and type system customization — the foundations of frameworks like Django ORM, SQLAlchemy, Pydantic, and dataclasses.
Python's Object Model
In Python everything is an object, including classes themselves. A class is an instance of type, and type is its own metaclass:
class MyClass:
pass
print(type(42)) # <class 'int'>
print(type(MyClass)) # <class 'type'>
print(type(type)) # <class 'type'> ← type is its own type
# Dynamically create a class with type(name, bases, dict)
Animal = type('Animal', (object,), {
'sound': 'generic',
'speak': lambda self: f"I am an animal, I make {self.sound}",
})
dog = Animal()
print(dog.speak()) # I am an animal, I make generic
Descriptors: The Engine Behind Attributes
A descriptor is any object that implements __get__, __set__, or __delete__. They power property, classmethod, staticmethod, and Django/SQLAlchemy fields.
Data descriptor vs. non-data descriptor
class RangeValidator:
"""Descriptor that validates a numeric value is within range."""
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = f"_{name}"
def __init__(self, minimum, maximum):
self.minimum = minimum
self.maximum = maximum
def __get__(self, obj, objtype=None):
if obj is None:
return self # Access from class, not instance
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
if not isinstance(value, (int, float)):
raise TypeError(f"{self.public_name} must be numeric, not {type(value).__name__}")
if not (self.minimum <= value <= self.maximum):
raise ValueError(
f"{self.public_name} must be between {self.minimum} and {self.maximum}, got: {value}"
)
setattr(obj, self.private_name, value)
def __delete__(self, obj):
raise AttributeError(f"{self.public_name} cannot be deleted")
class Product:
price = RangeValidator(0.01, 99_999.99)
stock = RangeValidator(0, 100_000)
discount = RangeValidator(0, 100)
def __init__(self, name, price, stock):
self.name = name
self.price = price # Goes through __set__
self.stock = stock
self.discount = 0
p = Product("Laptop", 999.99, 50)
print(p.price) # 999.99
try:
p.price = -10 # ValueError
except ValueError as e:
print(e)
try:
p.stock = "lots" # TypeError
except TypeError as e:
print(e)
Lazy attribute descriptor (instance-level cache)
class LazyAttribute:
"""Descriptor that computes value once and caches it on the instance."""
def __init__(self, function):
self.function = function
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Once computed, store in instance __dict__ (takes priority
# over non-data descriptor — no __set__ defined)
value = self.function(obj)
obj.__dict__[self.name] = value
return value
class Circle:
def __init__(self, radius):
self.radius = radius
@LazyAttribute
def area(self):
import math
print("Computing area...")
return math.pi * self.radius ** 2
@LazyAttribute
def perimeter(self):
import math
print("Computing perimeter...")
return 2 * math.pi * self.radius
c = Circle(5)
print(c.area) # "Computing area..." → 78.53...
print(c.area) # No message — uses cache from __dict__
print(c.perimeter) # "Computing perimeter..." → 31.41...
slots: Memory Optimization
By default, Python stores instance attributes in a __dict__ dictionary, consuming ~200-300 bytes per instance. With __slots__ you use fixed slot descriptors instead:
import sys
class PointDict:
def __init__(self, x, y, z):
self.x, self.y, self.z = x, y, z
class PointSlots:
__slots__ = ('x', 'y', 'z')
def __init__(self, x, y, z):
self.x, self.y, self.z = x, y, z
p_dict = PointDict(1.0, 2.0, 3.0)
p_slots = PointSlots(1.0, 2.0, 3.0)
print(sys.getsizeof(p_dict)) # ~48 bytes (object only)
print(sys.getsizeof(p_dict.__dict__)) # ~232 bytes (the dict)
print(sys.getsizeof(p_slots)) # ~64 bytes (no __dict__)
# With 1 million instances: typical savings of 150-200 MB
# __slots__ restrictions
try:
p_slots.w = 4.0 # AttributeError
except AttributeError as e:
print(e)
# Combining __slots__ with inheritance
class Point4D(PointSlots):
__slots__ = ('w',) # Only add the new attribute
def __init__(self, x, y, z, w):
super().__init__(x, y, z)
self.w = w
getattr and getattribute
class ConfigurableProxy:
"""Proxy that delegates missing attributes to an internal object."""
def __init__(self, base_object):
object.__setattr__(self, '_base', base_object)
object.__setattr__(self, '_accesses', {})
def __getattribute__(self, name):
# Called for ANY attribute access — infinite recursion risk
if name.startswith('_'):
return object.__getattribute__(self, name)
accesses = object.__getattribute__(self, '_accesses')
accesses[name] = accesses.get(name, 0) + 1
return object.__getattribute__(self, name)
def __getattr__(self, name):
# Only called when attribute NOT found on instance
base = object.__getattribute__(self, '_base')
return getattr(base, name)
def stats(self):
return object.__getattribute__(self, '_accesses')
import io
proxy = ConfigurableProxy(io.StringIO("hello world"))
proxy.read()
proxy.read()
proxy.seek(0)
print(proxy.stats()) # {'read': 2, 'seek': 1, ...}
Metaclasses: Class Factories
class SingletonMeta(type):
"""Metaclass implementing the Singleton pattern."""
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class Config(metaclass=SingletonMeta):
def __init__(self):
self.debug = False
self.db_url = "sqlite:///app.db"
c1 = Config()
c2 = Config()
print(c1 is c2) # True
class RegistryMeta(type):
"""Metaclass that auto-registers all subclasses."""
_registry: dict = {}
def __init_subclass__(cls, kind=None, **kwargs):
super().__init_subclass__(**kwargs)
if kind:
RegistryMeta._registry[kind] = cls
@classmethod
def get(mcs, kind: str):
if kind not in mcs._registry:
raise KeyError(f"Unknown kind: {kind}. Available: {list(mcs._registry)}")
return mcs._registry[kind]
class Converter(metaclass=RegistryMeta):
def convert(self, data): raise NotImplementedError
class PDFConverter(Converter, kind="pdf"):
def convert(self, data): return f"PDF: {data}"
class DocxConverter(Converter, kind="docx"):
def convert(self, data): return f"DOCX: {data}"
class Mp4Converter(Converter, kind="mp4"):
def convert(self, data): return f"MP4: {data}"
# Automatic factory pattern
converter = RegistryMeta.get("pdf")()
print(converter.convert("my_file")) # PDF: my_file
init_subclass: Modern Alternative to Metaclasses
class Plugin:
"""Plugin system without metaclasses."""
_plugins: dict[str, type] = {}
def __init_subclass__(cls, name: str = "", **kwargs):
super().__init_subclass__(**kwargs)
if name:
Plugin._plugins[name] = cls
@classmethod
def create(cls, name: str, *args, **kwargs):
if name not in cls._plugins:
raise ValueError(f"Unknown plugin: {name}")
return cls._plugins[name](*args, **kwargs)
class JSONPlugin(Plugin, name="json"):
def process(self, text): return f"JSON({text})"
class XMLPlugin(Plugin, name="xml"):
def process(self, text): return f"XML({text})"
p = Plugin.create("json")
print(p.process("data")) # JSON(data)
print(Plugin._plugins) # {'json': ..., 'xml': ...}
Complete dunder Protocol for Data Objects
from functools import total_ordering
import math
@total_ordering # generates __le__, __gt__, __ge__ from __eq__ and __lt__
class Vector:
__slots__ = ('_x', '_y', '_z')
def __init__(self, x, y, z):
self._x, self._y, self._z = float(x), float(y), float(z)
def __repr__(self): return f"Vector({self._x}, {self._y}, {self._z})"
def __str__(self): return f"({self._x:.2f}, {self._y:.2f}, {self._z:.2f})"
def __len__(self): return 3
def __getitem__(self, i): return (self._x, self._y, self._z)[i]
def __iter__(self): return iter((self._x, self._y, self._z))
def __add__(self, other): return Vector(self._x+other._x, self._y+other._y, self._z+other._z)
def __sub__(self, other): return Vector(self._x-other._x, self._y-other._y, self._z-other._z)
def __mul__(self, scalar): return Vector(self._x*scalar, self._y*scalar, self._z*scalar)
def __rmul__(self, scalar): return self.__mul__(scalar)
def __neg__(self): return Vector(-self._x, -self._y, -self._z)
def __abs__(self): return math.sqrt(self._x**2 + self._y**2 + self._z**2)
def __matmul__(self, other):return self._x*other._x + self._y*other._y + self._z*other._z
def __eq__(self, other): return abs(self) == abs(other)
def __lt__(self, other): return abs(self) < abs(other)
def __hash__(self): return hash((self._x, self._y, self._z))
def __bool__(self): return abs(self) > 0
def normalize(self):
m = abs(self)
return Vector(self._x/m, self._y/m, self._z/m)
v1 = Vector(1, 2, 3)
v2 = Vector(4, 5, 6)
print(v1 + v2) # (5.00, 7.00, 9.00)
print(v1 @ v2) # 32.0 (dot product)
print(3 * v1) # (3.00, 6.00, 9.00)
print(abs(v1)) # 3.741...
print(v1 < v2) # True
print(list(v1)) # [1.0, 2.0, 3.0]
print({v1, v2}) # hashable → usable in sets
Conclusion
Python metaprogramming is not dark magic — it is the natural consequence of a coherent object model where everything is an object and classes are first-class citizens. Descriptors let you create attributes with custom behavior without exposing getters/setters; __slots__ dramatically reduce memory usage for classes with millions of instances; and metaclasses (or the more modern __init_subclass__) implement patterns like Singleton, Registry, or automatic validation elegantly. Mastering these tools is what distinguishes an intermediate Python developer from an advanced one.
Related conversions
Frequent conversions across the catalogue: