Descriptors 101: How Object Attribute Access Works
A Python descriptor is any object that implements the descriptor protocol—a set of special methods __get__, __set__, and __delete__ that intercept attribute access. Descriptors are the foundation of properties, classmethods, staticmethods, and advanced frameworks like Django ORM. When you access an attribute on an instance, Python's attribute-lookup chain first checks the instance's own __dict__, then the class's __dict__, and when a descriptor is found in the class hierarchy, its __get__ method is invoked. Understanding descriptors is essential because they're embedded in Python's core; every professional Python framework uses them to create transparent, maintainable abstractions.
What Is the Descriptor Protocol?
The descriptor protocol is Python's mechanism for customizing how attributes are accessed, modified, or deleted on an object. A descriptor is a class that defines at least one of three special methods: __get__, __set__, or __delete__. When these methods are present on a class and an instance of that class is assigned to a class attribute, accessing that attribute on an instance triggers the descriptor's methods instead of normal attribute lookup.
Python's attribute-lookup mechanism checks descriptors before instance-level attributes (according to the Method Resolution Order), making descriptors a low-overhead way to add behavior to attribute access without modifying every getter/setter call. The property built-in (used with the @property decorator) is itself a descriptor—it has __get__ and optionally __set__ methods.
The Descriptor Lookup Chain
Python's attribute resolution follows a precise order. When you write obj.attr, Python executes this chain:
- Data descriptors in the class (have both
__get__and__set__). - Instance dictionary (
obj.__dict__['attr']). - Non-data descriptors (have only
__get__). - Class dictionary and parent classes.
- Raise AttributeError if not found.
This precedence is why properties (data descriptors) override instance attributes: they're checked first. Understanding this order prevents subtle bugs where instance state shadows descriptor behavior.
Implementing Your First Descriptor
A descriptor is a class you create; let's build a simple one that validates that an integer attribute stays within bounds:
class PositiveInteger:
"""A descriptor ensuring an attribute is a positive integer."""
def __init__(self, min_value=0, name=None):
self.min_value = min_value
self.name = name
def __set_name__(self, owner, name):
# Called automatically when the descriptor is assigned to a class attribute
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
# Accessing the descriptor from the class itself
return self
# Return the value stored in the instance's private namespace
return obj.__dict__.get(f'_{self.name}', None)
def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError(f'{self.name} must be an integer')
if value < self.min_value:
raise ValueError(f'{self.name} must be >= {self.min_value}')
# Store in a private instance attribute
obj.__dict__[f'_{self.name}'] = value
def __delete__(self, obj):
try:
del obj.__dict__[f'_{self.name}']
except KeyError:
raise AttributeError(f'no attribute {self.name}')
class Product:
price = PositiveInteger(min_value=1)
quantity = PositiveInteger(min_value=0)
def __init__(self, name, price, quantity):
self.name = name
self.price = price
self.quantity = quantity
# Usage
product = Product("Widget", price=19, quantity=5)
print(product.price) # Output: 19
# This raises ValueError because price < 1
try:
product.price = -5
except ValueError as e:
print(f'Error: {e}')
The __set_name__ method (available since Python 3.6) is automatically called when the descriptor is assigned to a class attribute, allowing the descriptor to know its attribute name without explicit declaration. This pattern eliminates boilerplate.
Properties: Descriptors in Disguise
The built-in @property decorator creates a descriptor. Here's how properties work under the hood:
class BankAccount:
def __init__(self, balance=0):
self._balance = balance
@property
def balance(self):
"""Getter: called when balance is read."""
print("Getting balance...")
return self._balance
@balance.setter
def balance(self, value):
"""Setter: called when balance is assigned."""
if value < 0:
raise ValueError("Balance cannot be negative")
print(f"Setting balance to {value}")
self._balance = value
@balance.deleter
def balance(self):
"""Deleter: called when del balance is executed."""
print("Deleting balance")
del self._balance
account = BankAccount(100)
print(account.balance) # Output: "Getting balance...\n100"
account.balance = 150 # Output: "Setting balance to 150"
del account.balance # Output: "Deleting balance"
The @property decorator returns a descriptor object with __get__, __set__, and __delete__ methods. When you assign @balance.setter, you're creating a new descriptor with an updated __set__ method. This approach keeps getter, setter, and deleter logic co-located and readable.
Comparison: Descriptors vs. Properties vs. Direct Attributes
| Approach | Pros | Cons | Use Case |
|---|---|---|---|
| Direct attributes | Simple, fast, no overhead | No validation or control logic | Simple data containers |
@property | Clean syntax, single attribute, readable | Slightly slower than direct access, bound to one instance | Per-instance validation, computed properties |
| Custom descriptors | Highly reusable across many attributes, shared validation | More boilerplate, steeper learning curve | DRY validation across models, ORM-like frameworks |
Key Takeaways
- A descriptor is a class with
__get__,__set__, or__delete__methods that intercepts attribute access on instances. - Data descriptors (with both
__get__and__set__) take precedence over instance attributes in Python's lookup chain. - Use
__set_name__(Python 3.6+) to let descriptors automatically discover their attribute name, eliminating boilerplate. - The
@propertydecorator is itself a descriptor; understanding properties is the gateway to understanding advanced descriptors. - Descriptors are the foundation of Django ORM fields, Pydantic validators, and SQLAlchemy columns.
Frequently Asked Questions
What is the difference between __getattr__ and descriptors?
__getattr__ is a magic method on instances that's called only when normal attribute lookup fails; it's less efficient and catches all missing attributes indiscriminately. Descriptors are invoked before instance-level attributes and are more controlled. Descriptors are preferred for structured attribute access; __getattr__ is useful for dynamic fallback behavior.
Do I need to implement all three descriptor methods?
No. A descriptor with only __get__ is a non-data descriptor; Python checks instance attributes first. A descriptor with __get__ and __set__ is a data descriptor and takes precedence over instance attributes. Only implement the methods you need.
Why use a descriptor instead of a simple setter/getter method?
Descriptors appear as attributes (e.g., obj.price) rather than method calls (e.g., obj.get_price()), making code more readable and Pythonic. Descriptors also integrate seamlessly with introspection tools, IDEs, and frameworks that expect attribute access patterns.
How do class methods and static methods use the descriptor protocol?
@classmethod and @staticmethod are both descriptors. When you call a classmethod, its __get__ method binds the class (not the instance) to the method. Staticmethods' __get__ returns the function unchanged, bypassing binding entirely.
Can descriptors slow down attribute access?
Yes, slightly. Each attribute access incurs a function call. For performance-critical loops, consider caching or accessing the underlying value directly. However, in most applications the clarity and validation they provide outweighs the negligible performance cost.
Further Reading
- Python Data Model: Descriptors — official Python documentation on the descriptor protocol.
- Descriptor HowTo Guide — Raymond Hettinger's comprehensive guide to descriptors and their real-world uses.
- Properties in Python: Getters and Setters — Real Python's in-depth article on properties and descriptors.
- SQLAlchemy Column Descriptors — see how SQLAlchemy uses descriptors for ORM field mapping.