The Descriptor Protocol
If you have used Python for any length of time, you have already used descriptors—even if you did not realize it. Every time you access an attribute on a class, Python’s descriptor protocol is at work behind the scenes. Understanding this protocol opens up a powerful way to write reusable, expressive code.
This guide will show you how descriptors work, when to use them, and how to avoid common pitfalls.
What Is a Descriptor?
A descriptor is any object that defines one or more of these special methods:
__get__(self, obj, objtype=None)— controls attribute access (read)__set__(self, obj, value)— controls attribute assignment (write)__delete__(self, obj)— controls attribute deletion__set_name__(self, owner, name)— called when the attribute is assigned to a class
When you access an attribute on a class, Python does not just look up the value in a dictionary. Instead, it looks for a descriptor object and, if found, calls the appropriate method.
Here is a simple example:
class Uppercase:
def __get__(self, obj, objtype=None):
return obj._value.upper()
def __set__(self, obj, value):
obj._value = value
class Person:
name = Uppercase()
def __init__(self, name):
self.name = name # This calls __set__
person = Person("alice")
print(person.name) # ALICE — __get__ was called
In this example, every time you read or write person.name, Python calls the methods on the Uppercase descriptor rather than storing the value directly.
Data Descriptors vs Non-Data Descriptors
Descriptors split into two categories, and the distinction matters:
Data descriptors define __set__ or __delete__. They always take precedence over instance dictionary lookups.
Non-data descriptors only define __get__. Methods and classmethods are non-data descriptors—Python looks them up once when the class is created, then reuses the same bound method object.
This is why assigning to an instance attribute can shadow a method:
class MyClass:
def method(self):
return "original"
obj = MyClass()
obj.method = lambda: "shadowed" # Shadows the method!
print(obj.method()) # Error: lambda object is not callable
If method were a data descriptor, this shadowing would not work—Python would always call __get__.
The set_name Method
Introduced in Python 3.6, __set_name__ lets descriptors know what name they were assigned to:
class Field:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
obj.__dict__[self.name] = value
class User:
username = Field()
email = Field()
When the class is created, Python calls Field.__set_name__(User, 'username') and Field.__set_name__(User, 'email'). The descriptor now knows its own name without hardcoding it.
Practical Example: Validated Attributes
Descriptors shine when you need reusable validation logic across multiple attributes or classes:
class Range:
def __init__(self, min_val, max_val):
self.min_val = min_val
self.max_val = max_val
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not (self.min_val <= value <= self.max_val):
raise ValueError(
f"{self.name} must be between {self.min_val} "
f"and {self.max_val}, got {value}"
)
obj.__dict__[self.name] = value
class Player:
health = Range(0, 100)
level = Range(1, 99)
def __init__(self, health, level):
self.health = health
self.level = level
player = Player(health=50, level=10)
player.health = 200 # Raises ValueError
This pattern gives you validation that works like a built-in attribute—no getter/setter boilerplate, just clean Python.
Common Built-in Descriptors
You have already used descriptors many times without knowing it:
property — the most common data descriptor:
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def diameter(self):
return self.radius * 2
classmethod — a non-data descriptor that binds to the class:
class Config:
_settings = {}
@classmethod
def load(cls, path):
# load from path into cls._settings
pass
staticmethod — also a non-data descriptor:
class Math:
@staticmethod
def add(a, b):
return a + b
When you use @property, @classmethod, or @staticmethod, you are defining descriptor objects that Python uses to manage attribute access.
Using delete
The __delete__ method lets you control what happens when someone tries to delete an attribute:
class Immutable:
def __init__(self, default):
self.default = default
def __get__(self, obj, objtype=None):
value = obj.__dict__.get(self.name)
if value is None:
return self.default
return value
def __set__(self, obj, value):
if self.name in obj.__dict__:
raise AttributeError(f"Cannot modify {self.name} after creation")
obj.__dict__[self.name] = value
class Config:
debug = Immutable(False)
def __init__(self, debug=False):
self.debug = debug
config = Config(True)
config.debug = False # Works
del config.debug # Raises: uses __delete__ if defined
Common Gotchas
Storing values: Descriptors should store values in the instance’s __dict__, not on the descriptor itself (unless you intentionally want shared state). This is what makes each instance have its own value:
class GoodDescriptor:
def __get__(self, obj, objtype=None):
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
obj.__dict__[self.name] = value # Good: instance storage
Using cls vs self: In __get__, obj is the instance (or None if accessed on the class). Use objtype when you need the class:
def __get__(self, obj, objtype=None):
if obj is None:
return self # Accessed on class, not instance
return obj.__dict__.get(self.name)
Descriptor ordering: When multiple descriptors are on the same class, Python calls them in definition order during attribute access.
When to Use Descriptors
Descriptors are the right tool when:
- You need reusable property logic across multiple attributes
- You want validation that feels like a native attribute
- You are building a framework that needs to manage attribute access
- You need lazy evaluation of expensive properties
For simple cases, consider whether a property decorator or regular method would be clearer. Descriptors add complexity—use them when the complexity pays off.
Conclusion
The descriptor protocol is one of Python’s most powerful but underused features. Key takeaways:
- Descriptors are objects with
__get__,__set__,__delete__, or__set_name__ - Data descriptors always take precedence over instance dictionary lookups
- Use
__set_name__to avoid hardcoding attribute names - Built-in decorators like
@propertyare descriptors in disguise
Once you understand descriptors, you will see them everywhere in Python—and you will have a powerful tool for writing expressive, reusable code.
See Also
property()— The built-in property decorator, a data descriptor__slots__— Using slots for memory efficiency in Python classespython-decorators— Python decorators explained, which use descriptor conceptspython-metaclasses— Understanding metaclasses, the next level of Python metaprogramming