Metaclasses and Descriptors
Advanced⏱90 minutes📖13 min readLesson 3 of 10

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

Metaclass
A class whose instances are classes. type is the default metaclass, and a custom metaclass subclasses it.
Class namespace
The mapping that holds the names defined in a class body before the class object exists.
__prepare__
A metaclass hook that creates the namespace object used while the class body runs.
__init_subclass__
A class method on the parent that runs after a subclass is created, without needing a metaclass.
__set_name__
A descriptor hook called once when the descriptor is assigned to a class attribute, with the owner class and attribute name.
Descriptor
An object that defines __get__, __set__ or __delete__ and controls attribute access on a class.
Data descriptor
A descriptor that defines __set__ or __delete__, so it takes precedence over the instance dictionary.
Non-data descriptor
A descriptor that only defines __get__, so an instance attribute with the same name wins.
Attribute lookup
The ordered search through data descriptors, the instance dictionary, then non-data descriptors and class attributes.
Deferred annotation
An annotation stored as code and evaluated on demand, as introduced by PEP 649 in Python 3.14.
Dataclass transform
A typing protocol that tells type checkers a decorator or base class generates methods such as __init__ from annotations.
Metaclass conflict
A TypeError raised when the bases of a class require incompatible metaclasses.

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.

python
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.

python
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.

100%
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.

python
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 = 2

The 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.

100%
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.

python
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.

mCodingWatch on YouTube
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.

python
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.

Ordering Question
How to answer

Ordering exercise with 6 items. Move them into the correct order, then check your answer.

Order the steps of class creation.

Call the metaclass __prepare__ method
Execute the class body in the returned namespace
Call the metaclass to build the class object
Call __set_name__ on each descriptor in the namespace
Call __init_subclass__ on the parent class
Apply class decorators from the inside out

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.

python
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.

python
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.

python
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.

python
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.

python
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.

python
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.

python
from enum import Enum class Status(Enum): OPEN = "open" CLOSED = "closed" print(Status("open") is Status.OPEN) print(list(Status))
100%
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.

Matching Question
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.

__prepare__
Drop here
__set_name__
Drop here
__init_subclass__
Drop here
__get__
Drop here
__getattr__
Drop here

Drag answers from here:

Before the class body executes
Once, when a descriptor is assigned in a class
After a subclass has been created
Every time an attribute is read
Only after normal lookup fails

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.

python
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.

python
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.

100%
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.

python
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.

python
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.

python
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.

100%
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.

python
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.

python
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.

plaintext
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.

plaintext
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.

python
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.

python
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

Practice Question
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?

Practice Question
How to answer

Multiple-choice question with 4 options. Select one option, then choose Check Answer.

When does __set_name__ run on a descriptor?

Practice Question
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?

Practice Question
How to answer

Multiple-choice question with 4 options. Select one option, then choose Check Answer.

What is the difference between __getattr__ and __getattribute__?

Practice Question
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?

Practice Question
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?

Practice Question
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?

Practice Question
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?

Practice Question
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?

Practice Question
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?

Fill in the Blanks
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:

__set_name__
✅Success

Key Takeaways

  1. A class statement prepares a namespace, runs the body, builds the class with a metaclass, names descriptors, then calls __init_subclass__.
  2. __init_subclass__ handles registration and validation without a metaclass and composes with other bases.
  3. __set_name__ is how a descriptor learns its attribute name. It runs once, at class creation.
  4. Data descriptors win over the instance dictionary. Non-data descriptors do not. That single rule explains properties, methods and cached_property.
  5. __getattr__ runs only after lookup fails. Overriding __getattribute__ intercepts everything and is rarely worth it.
  6. __slots__ creates data descriptors, saves memory and removes __dict__.
  7. PEP 649 defers annotation evaluation. Read them with annotationlib.get_annotations and an explicit format.
  8. Metaclasses are for namespace changes, base manipulation and class-level instantiation control. Everything else has a cheaper hook.
  9. 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.

  1. Python Software Foundation · Accessed 2026-11-08

  2. Python Software Foundation · Accessed 2026-11-08

  3. Python Software Foundation · Martin Teichmann · Accessed 2026-11-08

  4. Python Software Foundation · Accessed 2026-11-08

  5. Python Software Foundation · Accessed 2026-11-08

  6. [6]Descriptor HowTo GuideDocumentation

    Python Software Foundation · Accessed 2026-11-08

  7. Python Software Foundation · Eric V. Smith · Accessed 2026-11-08

  8. Python Software Foundation · Accessed 2026-11-08

  9. Python Software Foundation · Accessed 2026-11-08

  10. Python Software Foundation · Erik De Bonte · Accessed 2026-11-08

  11. Python Software Foundation · Accessed 2026-11-08

  12. [12]What's New In Python 3.13Documentation

    Python Software Foundation · Accessed 2026-11-08

  13. [13]What's New In Python 3.15Documentation

    Python Software Foundation · Accessed 2026-11-08

  14. GitHub · Django Software Foundation · Accessed 2026-11-08

  15. SQLAlchemy · Accessed 2026-11-08

  16. GitHub · Pydantic · Accessed 2026-11-08

Progress30%