Metaclasses and Descriptors
Follow the class creation pipeline, collect fields with __set_name__, understand the descriptor protocol, and read annotations correctly on Python 3.14 and 3.15
A team wants every model class in a service to declare a table name. The first
attempt is a base class with an __init__ check, which only runs when somebody
creates an instance. The second attempt is a metaclass, which works and then
breaks as soon as two base classes with different metaclasses appear in the same
class statement. The third attempt uses __init_subclass__, runs at class
creation time, needs no metaclass, and composes with other bases.
The lesson is not that metaclasses are bad. It is that class creation has a
pipeline, and each stage has a cheaper hook. __init_subclass__ is the hook for
subclass registration and validation. __set_name__ is the hook for naming a
descriptor. A metaclass is the hook for changing the namespace, the bases or the
creation process itself, and it should be the last tool you reach for.
Descriptors sit underneath the same pipeline. Every property, method, class
method, slot and cached property is a descriptor, and the attribute lookup rules
explain why a property wins over an instance dictionary while a
cached_property does not. Once you can read those rules, framework code stops
looking like magic.
Key terms
- A class whose instances are classes. type is the default metaclass, and a custom metaclass subclasses it.
- The mapping that holds the names defined in a class body before the class object exists.
- A metaclass hook that creates the namespace object used while the class body runs.
- A class method on the parent that runs after a subclass is created, without needing a metaclass.
- A descriptor hook called once when the descriptor is assigned to a class attribute, with the owner class and attribute name.
- An object that defines __get__, __set__ or __delete__ and controls attribute access on a class.
- A descriptor that defines __set__ or __delete__, so it takes precedence over the instance dictionary.
- A descriptor that only defines __get__, so an instance attribute with the same name wins.
- The ordered search through data descriptors, the instance dictionary, then non-data descriptors and class attributes.
- An annotation stored as code and evaluated on demand, as introduced by PEP 649 in Python 3.14.
- A typing protocol that tells type checkers a decorator or base class generates methods such as __init__ from annotations.
- A TypeError raised when the bases of a class require incompatible metaclasses.
Metaclass
Class namespace
__prepare__
__init_subclass__
__set_name__
Descriptor
Data descriptor
Non-data descriptor
Attribute lookup
Deferred annotation
Dataclass transform
Metaclass conflict
1. Everything Is an Object, Including the Class
type(1) is int, type(int) is type, and type(type) is type. A class is
an object created by a metaclass, and the default metaclass is type. Calling a
class runs type.__call__, which runs __new__ and then __init__ on the new
instance.
class Invoice:
currency = "USD"
print(type(Invoice))
print(type(Invoice()))
print(isinstance(Invoice, type))
print(Invoice.__mro__)The output shows <class 'type'> twice, then True, then the method resolution
order with object at the end. The class object lives in memory like any other
object, and you can add attributes to it, inspect it and pass it to functions.
class Invoice:
pass
Invoice.created_by = "import"
invoice = Invoice()
print(invoice.created_by)
print(vars(Invoice))vars(Invoice) shows the class namespace as a mapping. That mapping is the
object a metaclass can modify, and it is the object __prepare__ returns before
the class body runs.
2. The Class Creation Pipeline
A class statement is not a single operation. Python builds a namespace,
executes the body in that namespace, then calls the metaclass to create the
class object. The diagram shows the whole path.
Description
A flowchart of class creation from the class statement through the metaclass prepare hook, class body execution, type dot new, init subclass and set name hooks, to the finished class object.
The namespace is usually a plain dictionary. A metaclass can return an ordered
mapping or a custom mapping that records assignments, which is how some
frameworks preserve declaration order or reject duplicate field names. That is
the specific job of __prepare__, and it is one of the few jobs that genuinely
requires a metaclass.
class RecordingNamespace(dict):
def __setitem__(self, key, value):
print("define", key)
super().__setitem__(key, value)
class RecordingMeta(type):
@classmethod
def __prepare__(metacls, name, bases):
return RecordingNamespace()
class Example(metaclass=RecordingMeta):
first = 1
second = 2The output prints define first and define second before the class exists.
The namespace is a live object during class body execution, which is why this
hook cannot be replaced by a decorator.
Description
A sequence diagram of descriptor naming, where a class body creates a field object, the metaclass builds the class, type calls set name with the owner and attribute name, and the field stores both for later access.
type.__new__ calls __set_name__ on every value in the namespace that defines
it, passing the new class and the attribute name. This happens once, at class
creation, and it is the correct place for a descriptor to learn its own name.
The same pipeline can be driven without a class statement. types.new_class
takes the name, the bases, a keyword dictionary and a callback that populates the
namespace, and it resolves the metaclass the way the interpreter does.
Generated code and test helpers use it instead of calling type with three
arguments, because it runs __prepare__, __set_name__ and __init_subclass__
correctly.
import types
def populate(namespace):
namespace["answer"] = 42
Generated = types.new_class("Generated", (), {}, populate)
print(Generated.answer, type(Generated))The video below walks through the same machinery from the other direction, starting from a plain class and adding hooks until a metaclass appears, which is a useful way to see how little most code actually needs one.
Description
A video lesson that explains what a metaclass is, how type creates classes, and when a simpler hook such as init subclass is enough.
class Field:
def __set_name__(self, owner, name):
self.name = name
self.private = "_" + name
def __get__(self, instance, owner=None):
if instance is None:
return self
return getattr(instance, self.private)
def __set__(self, instance, value):
setattr(instance, self.private, value)
class User:
email = Field()
user = User()
user.email = "ada@example.com"
print(user.email)
print(User.email.name)The descriptor stores email, not a hard-coded string, so the same class works
for twenty fields. That is the pattern behind Django fields, SQLAlchemy columns
and Pydantic fields.
How to answer
Ordering exercise with 6 items. Move them into the correct order, then check your answer.
Order the steps of class creation.
3. __init_subclass__: The Hook You Usually Want
PEP 487 added __init_subclass__ so a parent can react to subclass creation
without a metaclass. It runs after the subclass exists, receives the
class keyword arguments, and composes with other base classes. A registry is the
canonical example.
class Plugin:
registry: dict[str, type["Plugin"]] = {}
def __init_subclass__(cls, *, name: str | None = None, **kwargs):
super().__init_subclass__(**kwargs)
key = name or cls.__name__.lower()
Plugin.registry[key] = cls
class CsvPlugin(Plugin, name="csv"):
pass
class JsonPlugin(Plugin):
pass
print(sorted(Plugin.registry))The output is ['csv', 'jsonplugin']. The keyword argument name is consumed by
the hook and is not passed to __init__, because it belongs to class creation,
not instance creation.
__init_subclass__ can also enforce a contract. The example below rejects a
subclass that forgets to set a class attribute, which is cheaper than an abstract
method when the requirement is about data rather than behaviour.
class Model:
table: str
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not getattr(cls, "table", None):
raise TypeError(f"{cls.__name__} must define table")
try:
class Broken(Model):
pass
except TypeError as error:
print(error)
class Good(Model):
table = "good"The failure happens when the class statement runs, which is the earliest useful moment. A test that imports the module catches it, and no instance is needed.
4. Metaclasses: When Class Creation Itself Changes
A metaclass is justified when you need to change the namespace, alter the bases,
inject methods, or control instantiation at the class level. Registration and
validation are usually better served by __init_subclass__. The remaining cases
are real, and frameworks use them.
class InterfaceMeta(type):
def __new__(mcls, name, bases, namespace, **kwargs):
cls = super().__new__(mcls, name, bases, namespace)
required = namespace.get("required", ())
for method in required:
if not callable(getattr(cls, method, None)):
raise TypeError(f"{name} must implement {method}")
return cls
class Repository(metaclass=InterfaceMeta):
required = ("save",)
class MemoryRepository(Repository):
def save(self, key, value):
print("saved", key)
try:
class BrokenRepository(Repository):
pass
except TypeError as error:
print(error)The metaclass sees the namespace before the class exists, so it can enforce rules across the class and its bases. A decorator can do the same thing after creation, and a decorator is easier to compose. Choose the metaclass only when the check must run before the class object is published, or when the class keywords must change the namespace.
class SingletonMeta(type):
_instances: dict[type, object] = {}
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):
pass
print(Config() is Config())The singleton works and is usually a mistake. A module-level instance, a
functools.cache factory or dependency injection gives the same single instance
without a global that tests cannot reset. The metaclass version also breaks
subclassing expectations and hides the dependency from the type checker.
Metaclass conflicts are the practical cost. A class whose bases have different
metaclasses raises TypeError unless one metaclass is a subclass of the other.
Mixing a framework base with a custom metaclass is the usual way to discover
this in production.
class MetaA(type):
pass
class MetaB(type):
pass
class A(metaclass=MetaA):
pass
class B(metaclass=MetaB):
pass
try:
class C(A, B):
pass
except TypeError as error:
print(type(error).__name__, error)5. Abstract Bases, Protocols and Enums
abc.ABCMeta is a metaclass. It records abstract methods, refuses to
instantiate a class that still has any, and supports virtual subclasses through
register. typing.Protocol uses its own metaclass to implement
structural subtyping, which is why isinstance works with runtime_checkable
protocols without an inheritance edge.
from abc import ABC, abstractmethod
class Store(ABC):
@abstractmethod
def get(self, key: str) -> str:
raise NotImplementedError
class MemoryStore(Store):
def get(self, key: str) -> str:
return key.upper()
print(MemoryStore().get("a"))
try:
Store()
except TypeError as error:
print(type(error).__name__)enum.Enum also uses a metaclass, named EnumType since 3.11 with EnumMeta
kept as an alias. It collects members, prevents duplicate names,
and builds the lookup tables. When you see a framework class that cannot be
subclassed with members, an enum metaclass is usually the reason.
from enum import Enum
class Status(Enum):
OPEN = "open"
CLOSED = "closed"
print(Status("open") is Status.OPEN)
print(list(Status))Description
A class diagram of the metaclass hierarchy with type at the root, ABCMeta, EnumType and a custom Meta subclassing it, and ordinary classes as instances of those metaclasses.
How to answer
Matching exercise with 5 pairs. Match each item on the left with its answer on the right.
Match each hook to the moment it runs.
Drag answers from here:
6. The Descriptor Protocol
A descriptor is an object that defines __get__, __set__ or __delete__. When
such an object is stored as a class attribute, attribute access calls those
methods instead of returning the object. The signature
for __get__ is (self, instance, owner). When the attribute is read from the
class, instance is None and owner is the class.
class Ten:
def __get__(self, instance, owner=None):
return 10
class Numbers:
value = Ten()
print(Numbers.value)
print(Numbers().value)Both reads return 10. The descriptor replaces itself for every access, which is
why a method stored on a class returns a bound method rather than the function
object.
The difference between a data descriptor and a non-data descriptor decides the
lookup order. A data descriptor defines __set__ or __delete__, so it wins
over the instance dictionary. A non-data descriptor defines only __get__, so
an instance attribute with the same name wins.
class DataDescriptor:
def __get__(self, instance, owner=None):
return "data"
def __set__(self, instance, value):
instance.__dict__["_value"] = value
class NonDataDescriptor:
def __get__(self, instance, owner=None):
return "non-data"
class Example:
data = DataDescriptor()
non_data = NonDataDescriptor()
example = Example()
example.__dict__["data"] = "instance"
example.__dict__["non_data"] = "instance"
print(example.data)
print(example.non_data)The output is data and instance. The data descriptor takes precedence, and
the non-data descriptor is shadowed. property is a data descriptor, which is
why a property with only a getter still wins over an instance dictionary entry
and raises AttributeError on assignment.
Description
A state diagram of attribute lookup, starting from a normal read, checking the type for a data descriptor, then the instance dictionary, then a non-data descriptor or class attribute, and finally getattr.
7. Descriptor Patterns You Will Actually Use
A validated field is the smallest useful descriptor. It stores the value in a private attribute and checks the type on assignment. The error is raised at the assignment, not at some later read.
class Positive:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner=None):
if instance is None:
return self
return instance.__dict__[self.name]
def __set__(self, instance, value):
if value <= 0:
raise ValueError(f"{self.name} must be positive")
instance.__dict__[self.name] = value
class Order:
quantity = Positive()
order = Order()
order.quantity = 2
print(order.quantity)
try:
order.quantity = 0
except ValueError as error:
print(error)functools.cached_property is a non-data descriptor. The first read computes the
value and stores it in the instance dictionary, and later reads find the
instance value before the descriptor. That is why it works only on instances
with a __dict__ and why deleting the cached value means deleting the instance
attribute.
from functools import cached_property
class Report:
def __init__(self, rows):
self.rows = rows
self.calls = 0
@cached_property
def total(self):
self.calls += 1
return sum(self.rows)
report = Report([1, 2, 3])
print(report.total, report.total, report.calls)
del report.total
print(report.total, report.calls)__getattr__ is called only when normal lookup fails, and __getattribute__ is
called for every access. Overriding __getattribute__ is almost always a
mistake: it intercepts methods, descriptors and dunder lookups, and a small
typo turns into infinite recursion.
class Lazy:
def __init__(self):
self._loaded = False
def __getattr__(self, name):
if name == "payload":
self._loaded = True
return {"loaded": True}
raise AttributeError(name)
lazy = Lazy()
print(lazy.payload)
print(lazy._loaded)Note that __getattr__ is not called for _loaded, because that attribute
exists in the instance dictionary. The method only handles missing names.
Description
A mind map of class customization choices, with registration and validation under init subclass, naming and field collection under set name, namespace changes under metaclasses, and plain decorators as the default.
8. Slots, Storage and Descriptor Interaction
__slots__ replaces the instance dictionary with a fixed set of slots. Each
slot is a descriptor on the class, so the lookup rules still apply: a slot is a
data descriptor, and it wins over any attempt to add an instance attribute with
the same name.
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
point = Point(1, 2)
print(point.x, point.y)
try:
point.z = 3
except AttributeError as error:
print(type(error).__name__)Slots save memory and prevent accidental attributes. They also break mixins that
expect __dict__, and they interact badly with multiple inheritance when two
bases both define non-empty slots. Dataclasses can generate slots for you, which
is usually the safer path.
from dataclasses import dataclass
@dataclass(slots=True)
class Point:
x: int
y: int
point = Point(1, 2)
print(point)
print(hasattr(point, "__dict__"))hasattr(point, "__dict__") is False. The generated class still has
descriptors for x and y, and the same lookup rules apply.
A class can expose both a slot and a property with the same name only by using a private slot, because the class body cannot bind two names to one attribute. The practical rule is to keep the storage name private and the public name a property or descriptor.
9. Annotations in 2026
Before 3.14, annotations were evaluated at definition time unless the module used
from __future__ import annotations. PEP 649 changes the default: annotations
are stored as code and evaluated when something asks for them, which removes the
need for string quotes around forward references. The 3.14 release notes
describe the change as the default behaviour for new modules, with the old
behaviour available through from __future__ import annotations for code that
has not migrated.
The annotationlib module exposes the formats explicitly. VALUE evaluates the
annotation, FORWARDREF replaces undefined names with placeholders, and
STRING returns the source text.
from annotationlib import Format, get_annotations
class Record:
name: str
count: int
missing: Undefined
print(get_annotations(Record, format=Format.STRING))
print(get_annotations(Record, format=Format.FORWARDREF))On Python 3.14 and newer, the STRING call returns the three annotations as
strings, and the FORWARDREF call returns a placeholder for Undefined instead
of raising NameError. The VALUE call raises NameError, because the name
really is undefined.
This matters for frameworks that inspect annotations at class creation. A field
collector should ask for FORWARDREF or STRING while the class is still being
built, and resolve names later when the module namespace is complete. Asking for
VALUE too early is the source of many import-order bugs.
import annotationlib
class Model:
name: str
annotate = Model.__annotate__
print(annotationlib.call_annotate_function(annotate, annotationlib.Format.STRING))__annotate__ is the function that PEP 649 attaches to modules, classes and
functions. Most application code should use get_annotations, and library code
that needs the raw function can use call_annotate_function.
PEP 681 gives type checkers a way to understand decorators and base classes that
generate methods from annotations, which is how dataclass, attrs and Pydantic
models get useful autocomplete. If you write such a library, annotate
the decorator with @dataclass_transform().
10. Debugging Class Machinery
Start with type, vars and inspect. vars(cls) shows the namespace,
cls.__mro__ shows the lookup order, and inspect.getmembers_static avoids
triggering descriptors while you inspect them.
import inspect
class Example:
@property
def value(self):
return 1
for name, member in inspect.getmembers_static(Example):
if not name.startswith("__"):
print(name, type(member).__name__)The property appears as a property object, not as 1. getmembers would have
called the getter on the class and returned the descriptor itself, which is
sometimes surprising but usually harmless.
Python 3.13 added __static_attributes__, a tuple of names assigned through
self anywhere in the class body, and __firstlineno__, the line where the
class definition starts. They are useful for linters and
for debugging generated classes.
class Example:
def __init__(self):
self.count = 0
print(Example.__static_attributes__)
print(Example.__firstlineno__ > 0)When a descriptor misbehaves, print type(cls).__mro__ and the descriptor's
__get__ result for instance=None. Most bugs come from forgetting the
instance is None branch, which breaks help(), dir() and class-level access.
11. What Changed in 2026
Change | Version | Why it matters |
|---|---|---|
PEP 649 deferred annotations | 3.14 | Annotations are evaluated on demand, and forward references no longer need quotes |
annotationlib formats | 3.14 | VALUE, FORWARDREF and STRING make annotation inspection explicit |
__static_attributes__ | 3.13 | Introspection can see which attributes a class assigns through self |
__firstlineno__ | 3.13 | Tools can report the class definition line |
EnumType | 3.11 | The enum metaclass has a public name, with EnumMeta as an alias |
dataclass_transform | 3.11 | Type checkers understand decorators that generate methods from annotations |
Cleaner generated __init__ annotations | 3.15 | Dataclass annotations no longer leak internal type names |
The practical change is that annotation readers should stop assuming
__annotations__ is a plain dictionary of evaluated values. Use
annotationlib.get_annotations with the format that matches the question, and
defer resolution until the namespace is complete. Python 3.15 also cleans up the
annotations that dataclasses writes onto generated __init__ methods, so tools
that read them no longer see private helper types.
12. Case Studies
Case study: Django's ModelBase
Django models are created by ModelBase, a metaclass that collects fields,
builds the table metadata and attaches descriptors to the class.
Each field contributes itself to the class and stores its column name, which is
why a field declared as email = models.EmailField() can later answer
User.email.field.name before any instance exists.
What to copy: collect metadata at class creation and keep the runtime object thin. What to avoid: importing private metaclass names into application code. Django's public API is the model base class, not the metaclass.
Case study: SQLAlchemy declarative mapping
SQLAlchemy's declarative layer builds mapped classes from annotations such as
Mapped[str] and mapped_column(). The declarative
base collects columns, relationships and table metadata when the subclass is
created, then instruments attribute access so a read can trigger a load.
What to copy: the annotation-driven declaration and the separation between mapping metadata and instance state. What to avoid: assuming the mapping is a plain dataclass. The instrumentation is the reason lazy loading works and the reason a detached instance raises.
Case study: Pydantic's model construction
Pydantic's ModelMetaclass collects fields, builds validators and generates the
core schema used at runtime. The class body reads like a
dataclass, and the metaclass turns it into a validator with error messages and
JSON schema output.
What to copy: the public surface is the model class and its configuration, not the metaclass. What to avoid: subclassing or importing the private metaclass. Pydantic 2.x moved most of that machinery into private modules precisely so applications stop depending on it.
13. Practice Questions
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A team wants every subclass of Service to register itself by name. Which hook is the smallest correct tool?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
When does __set_name__ run on a descriptor?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
Why does a property win over an entry in the instance dictionary?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
What is the difference between __getattr__ and __getattribute__?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
Why does cached_property not win over an instance attribute after the first read?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
Which annotationlib format replaces an undefined name with a placeholder instead of raising NameError?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
What is the public name of the enum metaclass since Python 3.11?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A class mixes two bases with unrelated metaclasses. What happens?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
Why does a dataclass field with a mutable default need default_factory?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
Which change makes introspection code work with deferred annotations?
How to answer
Fill-in-the-blank exercise with 1 blanks. Type the missing words, then check your answer.
Complete the descriptor hook that receives the owner class and attribute name.
class Field: def blank(self, owner, name): self.name = name
Word Bank, drag words into the blanks above:
Key Takeaways
- A class statement prepares a namespace, runs the body, builds the class with
a metaclass, names descriptors, then calls
__init_subclass__. __init_subclass__handles registration and validation without a metaclass and composes with other bases.__set_name__is how a descriptor learns its attribute name. It runs once, at class creation.- Data descriptors win over the instance dictionary. Non-data descriptors do
not. That single rule explains properties, methods and
cached_property. __getattr__runs only after lookup fails. Overriding__getattribute__intercepts everything and is rarely worth it.__slots__creates data descriptors, saves memory and removes__dict__.- PEP 649 defers annotation evaluation. Read them with
annotationlib.get_annotationsand an explicit format. - Metaclasses are for namespace changes, base manipulation and class-level instantiation control. Everything else has a cheaper hook.
- Django, SQLAlchemy and Pydantic all collect fields at class creation. Copy the pattern, not the private classes.
References
Every factual claim links to its source, with the access date printed so the source can be rechecked.