Of course! Let's dive deep into Python descriptors. They are a powerful and often misunderstood feature, but understanding them is key to unlocking a lot of "magic" in Python's standard library and frameworks like Django and SQLAlchemy.

What is a Descriptor?
At its core, a descriptor is a simple protocol. Any object that defines at least one of the following special methods can be considered a descriptor:
__get__(self, instance, owner)__set__(self, instance, value)__delete__(self, instance)
These methods allow an object to control how its attribute is accessed, set, or deleted. When you access an attribute on an object (e.g., obj.my_attr), Python looks for that attribute in a specific order. If it finds a descriptor in the class dictionary, it calls the descriptor's methods instead of just returning the object directly.
The Lookup Resolution Order
This is the most important concept to understand. When you access obj.x, Python performs this search:
- The instance dictionary:
obj.__dict__['x'] - The class dictionary:
obj.__class__.__dict__['x'] - The class's parent classes' dictionaries: (MRO search)
- If it finds a descriptor in step 2 or 3, it calls its
__get__method.
A descriptor only takes effect when it is accessed via the class or an instance of the class. If you access it directly from the class's dictionary, it behaves like a normal object.

The Three "Flavors" of Descriptors
Based on which methods are implemented, descriptors fall into three categories:
- Non-Data Descriptor: Only implements
__get__(). It's "passive" and doesn't manage storage. Functions are classic examples. - Data Descriptor: Implements both
__get__()and__set__(). It's "active" and controls access, including setting. Properties are the most common example. - Non-Descriptor: If an object defines neither
__get__nor__set__, it's just a normal object and follows the standard attribute lookup rules.
A crucial rule: Data descriptors always take precedence over non-data descriptors in the lookup order. This is why a property can override a method with the same name.
Let's Build Some Examples
Example 1: The Classic property (A Data Descriptor)
You might think @property is magic, but it's just a convenient way to create a data descriptor. Let's build our own version.
class MyProperty:
"""A simple re-implementation of the built-in property."""
def __init__(self, fget=None, fset=None, fdel=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
def __get__(self, instance, owner):
print("MyProperty __get__ called!")
if instance is None:
# Accessed on the class itself (e.g., Person.age)
return self
if self.fget is None:
raise AttributeError(f"unreadable attribute")
return self.fget(instance)
def __set__(self, instance, value):
print("MyProperty __set__ called!")
if self.fset is None:
raise AttributeError(f"can't set attribute")
self.fset(instance, value)
def setter(self, fset):
print("MyProperty.setter called!")
self.fset = fset
return self # Return self to allow decorator chaining
def deleter(self, fdel):
self.fdel = fdel
return self
# --- Usage ---
class Person:
def __init__(self, name, age):
self.name = name
# The descriptor is set here
self.age = age
# Use our custom property descriptor
age = MyProperty()
@age.setter
def age(self, value):
if not isinstance(value, int):
raise ValueError("Age must be an integer")
if value < 0:
raise ValueError("Age cannot be negative")
print(f"Setting age to {value}")
# We need to store the actual value somewhere else
# A common pattern is to use a unique name in the instance's __dict__
self._age = value
@property
def name(self):
print("MyProperty __get__ called for name!")
return self._name
@name.setter
def name(self, value):
print("MyProperty __set__ called for name!")
self._name = value
p = Person("Alice", 30)
# Accessing p.age triggers MyProperty.__get__
print(f"Person's age: {p.age}") # Output: MyProperty __get__ called! ... Person's age: 30
# Setting p.age triggers MyProperty.__set__
p.age = 31
print(f"Person's age: {p.age}") # Output: MyProperty __set__ called! ... Setting age to 31 ... Person's age: 31
# This demonstrates that the descriptor controls access, but the data is stored
# in a different attribute (_age) to avoid recursion.
print(f"Instance __dict__: {p.__dict__}") # Output: {'name': 'Alice', '_age': 31}
Example 2: A Verbose Logging Descriptor (A Non-Data Descriptor)
This descriptor will log every time an attribute is accessed, but it won't prevent the attribute from being set or deleted.

class VerboseAttribute:
"""A non-data descriptor that logs attribute access."""
def __init__(self, name):
self.name = name
print(f"VerboseAttribute for '{self.name}' created.")
def __get__(self, instance, owner):
if instance is None:
return self
print(f"*** ACCESSING {self.name} of {owner.__name__} instance ***")
# The actual value is stored in the instance's __dict__
return instance.__dict__[self.name]
# No __set__ or __del__, so it's a non-data descriptor.
class Point:
x = VerboseAttribute('x')
y = VerboseAttribute('y')
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(10, 20)
print("\nAccessing p.x:")
val_x = p.x
# Output:
# VerboseAttribute for 'x' created.
# VerboseAttribute for 'y' created.
#
# Accessing p.x:
# *** ACCESSING x of Point instance ***
print("\nAccessing p.y:")
val_y = p.y
# Output:
# *** ACCESSING y of Point instance ***
print(f"\nPoint coordinates: ({val_x}, {val_y})")
# Output: Point coordinates: (10, 20)
Example 3: A Smart Attribute for Validation (A Data Descriptor)
This is a very practical use case. We want an attribute that can only hold integers.
class IntegerAttribute:
"""A data descriptor that only accepts integers."""
def __init__(self, name):
# We use a unique internal name to store the value
self.internal_name = f"_{name}"
def __set__(self, instance, value):
if not isinstance(value, int):
raise TypeError(f"{self.internal_name[1:]} must be an integer")
setattr(instance, self.internal_name, value)
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.internal_name)
class BankAccount:
balance = IntegerAttribute("balance")
def __init__(self, initial_balance):
# This assignment triggers the descriptor's __set__
self.balance = initial_balance
def deposit(self, amount):
self.balance += amount
# --- Usage ---
acc = BankAccount(100)
print(f"Initial balance: {acc.balance}") # Output: Initial balance: 100
try:
acc.balance = "hello" # This will raise TypeError
except TypeError as e:
print(f"\nError: {e}") # Output: Error: _balance must be an integer
acc.deposit(50)
print(f"Balance after deposit: {acc.balance}") # Output: Balance after deposit: 150
When to Use Descriptors?
Descriptors are not for everyday use. They are a tool for building frameworks and reusable components. Here are the primary use cases:
-
Managing Attribute Access: This is the most common use.
- Properties: As shown,
@propertyis a data descriptor. - Read-Only Attributes: Create a descriptor with only
__get__. - Computed Attributes: Dynamically calculate a value when it's accessed.
- Properties: As shown,
-
Validation: As in the
IntegerAttributeexample, you can centralize validation logic for a specific attribute type. -
Lazy Loading: Only perform an expensive operation (like a database query) the first time an attribute is accessed. The descriptor can check if the value has been loaded and, if not, load it and store it in the instance's
__dict__. -
Caching: Store the result of a function call in the instance's
__dict__so subsequent calls are fast. -
Deprecation: Create a descriptor that warns the user when an old, deprecated attribute is accessed.
When NOT to Use Descriptors?
- For simple instance-specific state. If you just need to store data on an instance, do it directly in
__init__. The overhead of the descriptor protocol is unnecessary. - To replace simple
if/elselogic inside a method. A descriptor is too heavyweight for this.
Summary
| Feature | Description |
|---|---|
| Core Idea | A protocol (__get__, __set__, __delete__) that lets an object control another object's attribute access. |
| Lookup Order | Instance dict -> Class dict -> Parent dicts. If a descriptor is found in the class/parent dict, its method is called. |
| Data Descriptor | Has __get__ and __set__. Takes precedence over non-data descriptors. (e.g., @property) |
| Non-Data Descriptor | Has only __get__. Does not manage storage. (e.g., a method, a function) |
| Use Cases | Properties, validation, lazy loading, caching, building frameworks. |
| Don't Use For | Simple instance data storage or replacing basic logic. |
By mastering descriptors, you gain a much deeper understanding of how Python works under the hood and can write more elegant, powerful, and reusable code.
