Introduction
Descriptors are one of Python's most powerful and least understood features. They are the mechanism behind @property, @classmethod, @staticmethod, super(), and even how ordinary method calls work. Any time you access an attribute on a Python object, the descriptor protocol may be silently at work.
A descriptor is simply an object that defines one or more of the special methods __get__, __set__, or __delete__. When such an object is stored as a class attribute, Python intercepts attribute access and delegates to the descriptor's methods instead of performing a normal dictionary lookup.
Key Concepts
__get__(self, obj, objtype=None)-- Called when the attribute is read.objis the instance (orNoneif accessed from the class), andobjtypeis always the owner class.__set__(self, obj, value)-- Called when the attribute is assigned a value.__delete__(self, obj)-- Called when the attribute is deleted withdel.__set_name__(self, owner, name)-- Called at class creation time (Python 3.6+) to tell the descriptor what attribute name it was assigned to.
A descriptor must live on the class, not on the instance, for the protocol to activate.
Real World Context
Descriptors are everywhere in production Python:
- Django's model fields (
CharField,IntegerField) are descriptors. - SQLAlchemy's
Columnobjects use the descriptor protocol for attribute-mapped ORM. - Python's own
property,classmethod, andstaticmethodare implemented as descriptors. functools.cached_propertyis a non-data descriptor.
Deep Dive
Let's build a descriptor from scratch that validates positive numbers:
pythonclass PositiveNumber: """A descriptor that only allows positive numeric values.""" def __set_name__(self, owner, name): # Called automatically at class creation time self.name = name self.private_name = f'_descriptor_{name}' def __get__(self, obj, objtype=None): if obj is None: # Accessed from the class itself, e.g. Wallet.balance return self return getattr(obj, self.private_name, None) def __set__(self, obj, value): if not isinstance(value, (int, float)): raise TypeError(f"{self.name} must be a number") if value < 0: raise ValueError(f"{self.name} must be positive") setattr(obj, self.private_name, value) def __delete__(self, obj): if hasattr(obj, self.private_name): delattr(obj, self.private_name) else: raise AttributeError(f"{self.name} not set") class Wallet: balance = PositiveNumber() def __init__(self, balance): self.balance = balance # Calls PositiveNumber.__set__ w = Wallet(100) print(w.balance) # 100 -- calls PositiveNumber.__get__ w.balance = 200 # OK -- calls PositiveNumber.__set__ # w.balance = -50 # ValueError: balance must be positive
Notice that __set_name__ removes the need to pass the attribute name manually. Before Python 3.6, you had to write balance = PositiveNumber('balance'), which was error-prone and repetitive.
How Python Triggers Descriptors
When you write obj.attr, Python's attribute lookup roughly follows this order:
- Call
type(obj).__mro__to find the class and its bases. - Look for
attrin the class (and its MRO). If found and it is a data descriptor (has__get__and__set__or__delete__), call its__get__and return the result. - Look for
attrinobj.__dict__. If found, return it. - If the class attribute is a non-data descriptor (has only
__get__), call its__get__and return. - If none of the above, raise
AttributeError.
Common Pitfalls
- Storing state on the descriptor instead of the instance. If you write
self.value = valueinside__set__, all instances share the same value. Always store per-instance data onobj(the instance), not onself(the descriptor). - Forgetting that descriptors must be class attributes. Putting a descriptor in
__init__as an instance attribute does not trigger the protocol. - Not handling
obj is Nonein__get__. When the descriptor is accessed from the class (e.g.,MyClass.attr),objisNone. Returnselfor a helpful message in that case.
Best Practices
- Always implement
__set_name__to capture the attribute name automatically. - Store per-instance data using a mangled or prefixed private name on the instance, not on the descriptor itself.
- Return
selffrom__get__whenobj is Noneso the descriptor is inspectable from the class. - Combine descriptors with
__init_subclass__or metaclasses for framework-level validation.
Summary
The descriptor protocol (__get__, __set__, __delete__, __set_name__) lets you intercept attribute access on instances. Descriptors must be class-level attributes. They are the foundation of property, classmethod, staticmethod, and many Python frameworks. Always store instance data on the instance (not the descriptor) and implement __set_name__ for clean, automatic name capture.
Code Examples
class Lazy:
"""Non-data descriptor for lazy evaluation.
Computes the value on first access, then caches it
directly in the instance __dict__ so subsequent
accesses bypass the descriptor entirely."""
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, cls):
if obj is None:
return self
# Compute and cache in instance dict
value = self.func(obj)
setattr(obj, self.name, value)
return value
class Data:
@Lazy
def heavy_computation(self):
print("Computing...")
return sum(range(10**6))
d = Data()
print(d.heavy_computation) # Computing... -> 499999500000
print(d.heavy_computation) # 499999500000 (no recomputation)class TypeChecked:
"""Descriptor enforcing type at assignment time."""
def __init__(self, expected_type):
self.expected_type = expected_type
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
obj.__dict__[self.name] = value
class Person:
name = TypeChecked(str)
age = TypeChecked(int)
def __init__(self, name, age):
self.name = name
self.age = age
p = Person("Alice", 30) # OK
# Person("Alice", "old") # TypeError: age must be int, got str