Descriptors in Python¶
Descriptors are the core mechanism behind attribute access in Python.
Mental Model
A descriptor is an object that hijacks attribute access on another object. When a class attribute is a descriptor—an instance of a class that defines __get__, __set__, or __delete__—Python calls those methods instead of simply reading or writing the attribute. Descriptors only work when defined as class attributes; assigning them to instances has no effect. This single mechanism powers properties, methods, classmethod, staticmethod, and ORMs—understanding descriptors means understanding how Python’s object model actually works.
They power:
- methods
@propertyclassmethod/staticmethod- ORMs (Django, SQLAlchemy)
1. What is a Descriptor?¶
A descriptor is any object that implements one or more of:
python
__get__(self, obj, objtype=None)
__set__(self, obj, value)
__delete__(self, obj)
The __get__ parameters:
self--- the descriptor instance (lives on the class)obj--- the instance being accessed (Noneif accessed via the class)objtype--- the class that owns the descriptor (defaults toNonefor flexibility)
When accessed via an instance (obj.x), Python calls descriptor.__get__(obj, type(obj)). When accessed via the class (MyClass.x), Python calls descriptor.__get__(None, MyClass). This is why descriptors typically check if obj is None: return self.
2. Types of Descriptors¶
Data Descriptor¶
Implements __get__ AND __set__ (or __delete__):
```python class DataDescriptor: def get(self, obj, objtype=None): if obj is None: return self # accessed via class return obj._x # accessed via instance
def __set__(self, obj, value):
obj._x = value
```
Takes precedence over instance attributes.
Full Trace¶
```python class MyClass: x = DataDescriptor() # descriptor lives on the class
obj = MyClass() obj.x = 10 # calls DataDescriptor.set(descriptor, obj, 10) # self = MyClass.dict["x"] (the descriptor) # obj = the MyClass instance # value = 10 # stores obj._x = 10
print(obj.x) # calls DataDescriptor.get(descriptor, obj, MyClass) # returns obj._x → 10 ```
The descriptor stores data in obj._x (not obj.x) to avoid infinite recursion --- writing to obj.x would trigger __set__ again.
Why if obj is None: return self¶
Descriptors serve two roles depending on how you access them:
python
obj.x # instance access → obj is the instance → return the VALUE
MyClass.x # class access → obj is None → return the DESCRIPTOR
| Expression | obj |
Returns | Used for |
|---|---|---|---|
obj.x |
the instance | Computed value (obj._x) |
Normal program logic |
MyClass.x |
None |
The descriptor object itself | Inspection, frameworks, debugging |
Without the if obj is None guard, MyClass.x would try None._x and crash with AttributeError. Returning self lets frameworks inspect the descriptor (this is how Django discovers model fields and how type(MyClass.x) shows <property>).
python
obj = MyClass()
obj.x = 10
print(obj.x) # 10 — instance access, returns value
print(MyClass.x) # <DataDescriptor object> — class access, returns descriptor
Non-Data Descriptor¶
Implements only __get__:
python
class NonDataDescriptor:
def __get__(self, obj, objtype=None):
return 42
Lower priority than instance attributes.
3. Descriptor in Action¶
```python class A: x = NonDataDescriptor()
a = A() print(a.x) # calls x.get(a, A) ```
4. Where Descriptors Are Used¶
4.1 Methods¶
python
class A:
def f(self): pass
f is a non-data descriptor. Accessing a.f calls:
python
A.__dict__['f'].__get__(a, A)
This returns a bound method --- an object that pairs the function with the instance, so calling it automatically passes the instance as the first argument (self).
flowchart LR
A["obj.f"] -->|"descriptor __get__"| B["bound method (f + obj)"]
B -->|"call"| C["f(obj)"]
A bound method holds two references: __func__ (the original function) and __self__ (the instance). When you call obj.f(), Python calls f(obj) behind the scenes. A new bound method is created on every access, so obj.f is obj.f is False (different objects), even though obj.f == obj.f is True (same function + same instance).
python
a = A()
first = a.f
second = a.f
print(first is second) # False — different objects
print(first == second) # True — same __func__ and __self__
print(first.__func__ is second.__func__) # True — same function
print(first.__self__ is second.__self__) # True — same instance
4.2 Property¶
python
class A:
@property
def x(self):
return 10
property is a data descriptor --- it always intercepts access, even if a key of the same name exists in the instance __dict__.
The decorated method provides __get__. The property object itself also defines __set__ (raising AttributeError if no setter is provided), making it a data descriptor. This is not Python "auto-adding" __set__ --- the property class comes with __set__ built in.
```python a = A() print(a.x) # 10 — get runs
a.x = 99 # AttributeError — set exists but raises¶
```
4.3 Classmethod / Staticmethod¶
```python class A: @classmethod def f(cls): pass
@staticmethod
def g(): pass
```
| Type | Descriptor Type |
|---|---|
classmethod |
non-data (binds to class) |
staticmethod |
non-data (returns raw function) |
5. Descriptor vs Instance Dictionary¶
With a non-data descriptor:
```python class A: x = NonDataDescriptor()
a = A() a.x = 100 print(a.x) # 100 — instance dict wins ```
With a data descriptor:
```python class A: x = DataDescriptor()
a = A() a.x = 100 print(a.x) # descriptor controls access ```
6. Descriptor Priority¶
text
1. Data descriptor
2. Instance __dict__
3. Non-data descriptor
4. Class attribute
7. How Python Uses Descriptors¶
When you do:
python
obj.attr
Python may call:
python
descriptor.__get__(obj, type(obj))
Descriptors are triggered inside __getattribute__. See __getattribute__ vs __getattr__ for how this fits into the full pipeline.
8. Minimal Custom Descriptor Example¶
```python class LoggedAttribute: def set_name(self, owner, name): self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
print(f"Getting {self.name}")
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
print(f"Setting {self.name} = {value}")
obj.__dict__[self.name] = value
class A: x = LoggedAttribute()
a = A() a.x = 10 # Setting x = 10 print(a.x) # Getting x → 10 ```
9. Key Insight¶
Descriptors are the engine behind Python attribute behavior. They unify:
- methods → binding (
self) - properties → controlled access
- frameworks → dynamic fields
Summary¶
- Descriptor = object controlling attribute access via
__get__,__set__,__delete__ - Data descriptor > instance attribute > non-data descriptor
- Methods and properties are descriptors
- Core to Python OOP internals
See Also¶
Exercises¶
Exercise 1.
Create a non-data descriptor Verbose that prints a message and returns a fixed value from __get__. Attach it to a class Host. Show that host.attr triggers the descriptor. Then assign host.attr = "direct" and show that the instance __dict__ entry now shadows the non-data descriptor.
Solution to Exercise 1
class Verbose:
def __get__(self, obj, objtype=None):
if obj is None:
return self
print("Descriptor __get__ called")
return 42
class Host:
attr = Verbose()
host = Host()
print(host.attr)
# Descriptor __get__ called
# 42
host.attr = "direct"
print(host.attr) # "direct" — instance dict shadows descriptor
print(host.__dict__) # {'attr': 'direct'}
Exercise 2.
Create a data descriptor Positive that only allows positive numbers. Implement __set__ to raise ValueError for non-positive values, and __get__ to return the stored value. Use __set_name__ to automatically capture the attribute name. Demonstrate that obj.x = -5 raises an error.
Solution to Exercise 2
class Positive:
def __set_name__(self, owner, name):
self.name = name
self.storage = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.storage, None)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.name} must be positive, got {value}")
setattr(obj, self.storage, value)
class Account:
balance = Positive()
acc = Account()
acc.balance = 100
print(acc.balance) # 100
try:
acc.balance = -5
except ValueError as e:
print(e) # balance must be positive, got -5
Exercise 3.
Predict the output of both print statements. Explain why MyClass.x and obj.x return different things.
```python class Demo: def get(self, obj, objtype=None): if obj is None: return "I am the descriptor" return "I am the value"
class MyClass: x = Demo()
obj = MyClass() print(MyClass.x) print(obj.x) ```
Solution to Exercise 3
class Demo:
def __get__(self, obj, objtype=None):
if obj is None:
return "I am the descriptor"
return "I am the value"
class MyClass:
x = Demo()
obj = MyClass()
print(MyClass.x) # "I am the descriptor"
print(obj.x) # "I am the value"
# MyClass.x calls __get__(descriptor, None, MyClass)
# → obj is None → returns "I am the descriptor"
#
# obj.x calls __get__(descriptor, obj, MyClass)
# → obj is not None → returns "I am the value"
#
# This is the two-role pattern: class access returns the
# descriptor itself (for inspection), instance access
# returns the managed value (for program logic).
Exercise 4. Explain why a data descriptor cannot be shadowed by an instance attribute, but a non-data descriptor can. Write code that proves both behaviors.
Solution to Exercise 4
class DataDesc:
def __get__(self, obj, objtype=None):
return "from data descriptor"
def __set__(self, obj, value):
print(f"__set__ intercepted: {value}")
class NonDataDesc:
def __get__(self, obj, objtype=None):
return "from non-data descriptor"
class MyClass:
x = DataDesc()
y = NonDataDesc()
obj = MyClass()
# Try to shadow data descriptor
obj.x = "shadow"
# prints: __set__ intercepted: shadow
print(obj.x) # "from data descriptor" — NOT shadowed
# Shadow non-data descriptor
obj.y = "shadow"
print(obj.y) # "shadow" — instance dict wins
# Why: data descriptors (tier 1) are checked BEFORE instance
# __dict__ (tier 2). Non-data descriptors (tier 3) are checked
# AFTER. So instance __dict__ can override tier 3 but not tier 1.