Descriptor Introduction¶
What Is a Descriptor?¶
Mental Model
Without a descriptor, obj.attr simply returns the stored value. With a descriptor, Python calls the descriptor's __get__ method instead — the descriptor decides what to return. In short, a descriptor replaces "return the stored object" with "run custom logic and return the result."
1. Definition¶
A descriptor is any object that defines at least one of these three special methods:
__get__(self, instance, owner)- controls getting an attribute__set__(self, instance, value)- controls setting an attribute__delete__(self, instance)- controls deleting an attribute
2. Key Concept¶
Descriptors are objects that live on classes but manage attribute access for instances.
```python class MyDescriptor: def get(self, instance, owner): return "descriptor value"
class MyClass: attr = MyDescriptor() # Descriptor lives here (class level)
obj = MyClass() print(obj.attr) # "descriptor value" (accessed on instance) ```
3. Descriptors ARE Python OOP¶
Most advanced attribute behavior in Python OOP is implemented using descriptors:
| Feature | Descriptor Type | Why |
|---|---|---|
@property |
Data descriptor | Has __get__ + __set__ (even if setter raises) |
| Methods (functions) | Non-data descriptor | Only __get__ (binds self to produce bound method) |
@classmethod |
Non-data descriptor | Only __get__ (binds to class instead of instance) |
@staticmethod |
Non-data descriptor | Only __get__ (returns raw function, no binding) |
| ORM fields (Django, SQLAlchemy) | Data descriptor | Has __get__ + __set__ (controls DB read/write) |
4. The Three Rules of Attribute Lookup¶
Attribute lookup is a priority competition run in phases, not per-class loops. Each phase scans the entire MRO before moving on:
- Data descriptor wins — scan all classes in MRO; if any has a data descriptor for that name, it intercepts the access immediately.
- Instance
__dict__wins — if no data descriptor was found anywhere, the instance's own dictionary is checked. - Non-data descriptor / class attribute runs — if nothing is found on the instance, scan MRO again for non-data descriptors and plain class attributes.
See Attribute Access and Lookup for the full pipeline and Data vs Non-Data for why the distinction matters.
5. Three Layers of the System¶
This section covers three layers, each building on the previous:
```text Layer 1: Attribute system getattribute, getattr, setattr, delattr
Layer 2: Descriptor protocol get, set, delete
Layer 3: Applications properties, methods, ORM, caching ```
Layer 1 defines when the hooks run. Layer 2 defines what the hooks do. Layer 3 is what you build with them.
If you understand descriptors, you understand Python's attribute access system.
How Python Uses Descriptors¶
Descriptors plug into Python's attribute lookup pipeline. When you access obj.attr, data descriptors are checked before the instance __dict__, while non-data descriptors are checked after. See Attribute Access and Lookup for the complete resolution order.
Descriptor Lives on Class, Manages Instance Access¶
```python class Descriptor: def get(self, instance, owner): if instance is None: return self # Accessed from class return f"Value for {instance}" # Accessed from instance
class MyClass: attr = Descriptor() # Lives in class namespace
Accessing from class — instance is None¶
print(MyClass.attr) #
Accessing from instance — instance is the object¶
obj = MyClass()
print(obj.attr) # "Value for
Simple Example¶
1. Basic Descriptor¶
```python class SimpleDescriptor: def init(self, name): self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
print(f"Getting {self.name}")
return instance.__dict__.get(self.name, None)
def __set__(self, instance, value):
print(f"Setting {self.name} = {value}")
instance.__dict__[self.name] = value
class MyClass: x = SimpleDescriptor('x') y = SimpleDescriptor('y')
obj = MyClass() obj.x = 10 # Setting x = 10 print(obj.x) # Getting x → 10
obj.y = 20 # Setting y = 20 print(obj.y) # Getting y → 20 ```
2. Why Use This?¶
Instead of: ```python class MyClass: def get_x(self): return self._x
def set_x(self, value):
self._x = value
```
You can:
python
class MyClass:
x = ManagedAttribute('x')
3. Benefits¶
- ✅ Cleaner syntax
- ✅ Reusable attribute logic
- ✅ Attribute-style access with method-level control
- ✅ DRY (Don't Repeat Yourself)
Where Descriptors Shine¶
1. Validation¶
```python class TypedDescriptor: def init(self, name, expected_type): self.name = name self.expected_type = expected_type
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}"
)
instance.__dict__[self.name] = value
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
class Person: name = TypedDescriptor('name', str) age = TypedDescriptor('age', int)
p = Person() p.name = "Alice" # ✅ OK p.age = 30 # ✅ OK
p.age = "30" # ❌ TypeError¶
```
2. Lazy Loading¶
```python class LazyProperty: def init(self, func): self.func = func
def __get__(self, instance, owner):
if instance is None:
return self
value = self.func(instance)
# Cache by replacing descriptor with value
setattr(instance, self.func.__name__, value)
return value
class DataLoader: @LazyProperty def data(self): print("Loading data...") return [1, 2, 3, 4, 5]
obj = DataLoader() print(obj.data) # Loading data... [1, 2, 3, 4, 5] print(obj.data) # [1, 2, 3, 4, 5] (no loading) ```
3. Computed Properties¶
```python class Quantity: def init(self, name): self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name, 0)
def __set__(self, instance, value):
if value < 0:
raise ValueError("Quantity cannot be negative")
instance.__dict__[self.name] = value
class Product: price = Quantity('price') quantity = Quantity('quantity')
@property
def total(self):
return self.price * self.quantity
prod = Product() prod.price = 10 prod.quantity = 5 print(prod.total) # 50 ```
Descriptor vs Property¶
1. Property Is a Descriptor¶
```python
Using property¶
class Example: @property def x(self): return self._x
@x.setter
def x(self, value):
self._x = value
Property is actually a descriptor!¶
print(type(Example.dict['x'])) #
2. When to Use Each¶
Use @property when:
- One-off attribute with custom logic
- Simple getter/setter/deleter
- Specific to one class
Use descriptor when:
- Reusable across multiple classes
- Complex attribute management
- Need to share logic
3. Comparison¶
```python
Property - specific to one attribute¶
class Circle: @property def area(self): return 3.14 * self.radius ** 2
Descriptor - reusable pattern¶
class PositiveNumber: def init(self, name): self.name = name
def __set__(self, instance, value):
if value <= 0:
raise ValueError("Must be positive")
instance.__dict__[self.name] = value
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name, 1)
class Rectangle: width = PositiveNumber('width') height = PositiveNumber('height')
class Circle: radius = PositiveNumber('radius') ```
Why Descriptors Matter¶
Descriptors enable reusable attribute logic — write validation, type checking, or access control once and apply it across many classes:
```python
Define once¶
class NonNegative: def set_name(self, owner, name): self.name = name
def __set__(self, instance, value):
if value < 0:
raise ValueError("Must be non-negative")
instance.__dict__[self.name] = value
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name, 0)
Use everywhere¶
class BankAccount: balance = NonNegative()
class ShoppingCart: total = NonNegative()
class Inventory: quantity = NonNegative() ```
Frameworks like Django and SQLAlchemy use descriptors extensively — every ORM field is a descriptor that handles database storage and retrieval. See Descriptor Use Cases for validation, ORM, caching, and access control patterns.
Exercises¶
Exercise 1.
Create a simple descriptor Uppercase that, when a string value is set, automatically converts it to uppercase. Use __set_name__, __get__, and __set__. Apply it to a User class with a username field. Show that user.username = "alice" stores "ALICE".
Solution to Exercise 1
class Uppercase:
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):
obj.__dict__[self.name] = value.upper()
class User:
username = Uppercase()
def __init__(self, username):
self.username = username
u = User("alice")
print(u.username) # ALICE
u.username = "bob"
print(u.username) # BOB
Exercise 2.
Write a ReadOnly descriptor that allows a value to be set once (in __init__) but raises AttributeError on any subsequent assignment. Use it in a Product class for the sku field. Show that the SKU can be set during construction but not changed afterward.
Solution to Exercise 2
class ReadOnly:
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 self.name in obj.__dict__:
raise AttributeError(f"'{self.name}' is read-only")
obj.__dict__[self.name] = value
class Product:
sku = ReadOnly()
def __init__(self, sku, name):
self.sku = sku
self.name = name
p = Product("ABC-123", "Widget")
print(p.sku) # ABC-123
try:
p.sku = "NEW-456"
except AttributeError as e:
print(f"Error: {e}") # Error: 'sku' is read-only
Exercise 3.
Build a Logged descriptor that prints a message every time a value is set or accessed. Include the attribute name, old value, and new value in set messages. Apply it to a Settings class with theme and language fields. Demonstrate the logging output.
Solution to Exercise 3
class Logged:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = obj.__dict__.get(self.name)
print(f"GET {self.name} -> {value!r}")
return value
def __set__(self, obj, value):
old = obj.__dict__.get(self.name, "<unset>")
print(f"SET {self.name}: {old!r} -> {value!r}")
obj.__dict__[self.name] = value
class Settings:
theme = Logged()
language = Logged()
def __init__(self, theme, language):
self.theme = theme
self.language = language
s = Settings("dark", "en")
# SET theme: '<unset>' -> 'dark'
# SET language: '<unset>' -> 'en'
s.theme
# GET theme -> 'dark'
s.theme = "light"
# SET theme: 'dark' -> 'light'