Descriptors Advanced¶
When you'd use this
get, set, delete — the mechanism behind @property, classmethod, staticmethod and ORMs.
Control attribute access at the class level — the machinery behind @property, ORMs, validation, and lazy/computed attributes.
What is a descriptor?¶
Introduces a descriptor and where it fits in Descriptors.
A descriptor is any object that defines at least one of:
__get__(self, obj, objtype=None)— intercept attribute read__set__(self, obj, value)— intercept attribute write__delete__(self, obj)— intercept attribute deletion
When a descriptor is assigned as a class variable, Python calls these methods automatically instead of returning the descriptor object itself.
class Verbose:
"""A descriptor that logs every access."""
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self # class-level access
value = obj.__dict__.get(self.name)
print(f" GET {self.name} → {value!r}")
return value
def __set__(self, obj, value):
print(f" SET {self.name} = {value!r}")
obj.__dict__[self.name] = value
def __delete__(self, obj):
print(f" DEL {self.name}")
obj.__dict__.pop(self.name, None)
class Person:
name = Verbose()
age = Verbose()
def __init__(self, name, age):
self.name = name # triggers __set__
self.age = age
p = Person("Alice", 30)
# Output:
# SET name = 'Alice'
# SET age = 30
print(p.name)
# Output:
# GET name → 'Alice'
# Alice
del p.age
# Output:
# DEL age
Data Descriptors vs Non-data Descriptors¶
The distinction decides precedence against the instance __dict__: data descriptors (define __set__/__delete__) win over instance attributes; non-data ones (only __get__) don't. It's why @property can't be shadowed but a cached value can.
This distinction controls priority in attribute lookup.
| Type | Defines | Priority |
|---|---|---|
| Data descriptor | __get__ AND (__set__ or __delete__) | Higher than instance __dict__ |
| Non-data descriptor | Only __get__ | Lower than instance __dict__ |
class DataDesc:
"""Data descriptor — wins over instance __dict__."""
def __get__(self, obj, objtype=None):
return "from data descriptor"
def __set__(self, obj, value):
pass # intercepts writes
class NonDataDesc:
"""Non-data descriptor — instance __dict__ can override."""
def __get__(self, obj, objtype=None):
return "from non-data descriptor"
class MyClass:
data = DataDesc()
nondata = NonDataDesc()
obj = MyClass()
obj.__dict__["data"] = "instance value"
obj.__dict__["nondata"] = "instance value"
print(obj.data) # "from data descriptor" ← descriptor wins
print(obj.nondata) # "instance value" ← instance wins
Why this matters: @property is a data descriptor (has __set__), which is why you can't accidentally override it with an instance variable.
The Descriptor Resolution Order¶
The exact lookup order that decides when a descriptor's __get__ runs vs the instance dict.
When you access obj.attr, Python follows this lookup chain:
1. type(obj).__mro__ → search for data descriptor
2. obj.__dict__ → search instance dict
3. type(obj).__mro__ → search for non-data descriptor
4. Raise AttributeError
The actual CPython C implementation (simplified):
def object_getattribute(obj, name):
cls = type(obj)
descriptor = None
# Search the MRO for the name
for base in cls.__mro__:
if name in base.__dict__:
descriptor = base.__dict__[name]
break
# Is it a data descriptor?
desc_get = getattr(type(descriptor), '__get__', None)
desc_set = getattr(type(descriptor), '__set__', None)
if desc_get and desc_set: # data descriptor
return desc_get(descriptor, obj, cls)
# Check instance dict
if name in obj.__dict__:
return obj.__dict__[name]
# Non-data descriptor
if desc_get:
return desc_get(descriptor, obj, cls)
# Plain class variable
if descriptor is not None:
return descriptor
raise AttributeError(name)
__set_name__ — knowing your own name¶
Python calls __set_name__ at class creation so a descriptor learns the attribute name it's bound to — use it to pick a per-instance storage key automatically instead of hardcoding one.
Added in Python 3.6, called automatically when the class is created:
class Field:
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
setattr(obj, self.private_name, value)
class Point:
x = Field()
y = Field()
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(3, 4)
print(p.x) # 3
print(p.__dict__) # {'_x': 3, '_y': 4}
print(Point.x) # <Field object>
print(Point.x.public_name) # 'x'
Practical Use Case 1: Type-Validated Fields¶
Practical Use Case 1: Type-Validated Fields in Descriptors — what it is and when to use it.
class Typed:
def __init__(self, expected_type):
self.expected_type = expected_type
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 not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
obj.__dict__[self.name] = value
def __delete__(self, obj):
del obj.__dict__[self.name]
class Employee:
name = Typed(str)
age = Typed(int)
salary = Typed(float)
def __init__(self, name, age, salary):
self.name = name
self.age = age
self.salary = salary
e = Employee("Alice", 30, 75000.0)
print(e.name) # Alice
print(e.age) # 30
try:
e.age = "thirty"
except TypeError as ex:
print(ex) # age must be int, got str
try:
Employee("Bob", "25", 50000.0)
except TypeError as ex:
print(ex) # age must be int, got str
Practical Use Case 2: Range-Validated Fields¶
Practical Use Case 2: Range-Validated Fields in Descriptors — what it is and when to use it.
class RangeChecked:
def __init__(self, min_val=None, max_val=None):
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):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if self.min_val is not None and value < self.min_val:
raise ValueError(f"{self.name} must be >= {self.min_val}")
if self.max_val is not None and value > self.max_val:
raise ValueError(f"{self.name} must be <= {self.max_val}")
obj.__dict__[self.name] = value
class Temperature:
celsius = RangeChecked(min_val=-273.15, max_val=1e9)
def __init__(self, celsius):
self.celsius = celsius
@property
def fahrenheit(self):
return self.celsius * 9/5 + 32
t = Temperature(100)
print(t.celsius) # 100
print(t.fahrenheit) # 212.0
try:
Temperature(-300)
except ValueError as ex:
print(ex) # celsius must be >= -273.15
Practical Use Case 3: Lazy/Cached Properties¶
Practical Use Case 3: Lazy/Cached Properties in Descriptors — what it is and when to use it.
class Lazy:
"""Non-data descriptor that caches the result in instance __dict__."""
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Compute once, then store in instance dict
# Next access bypasses this descriptor (non-data!)
value = self.func(obj)
obj.__dict__[self.name] = value
return value
class DataFile:
def __init__(self, path):
self.path = path
@Lazy
def contents(self):
print(f" Reading {self.path}...")
with open(self.path) as f:
return f.read()
# First access computes
df = DataFile("config.txt")
# print(df.contents) → "Reading config.txt..."
# print(df.contents) → returns cached, no print
This is how functools.cached_property works
Python 3.8+ includes @cached_property which is exactly this pattern.
How @property Works Under the Hood¶
property is just a data descriptor — this shows the mechanism.
property is a data descriptor class built into Python:
class property:
"""Simplified reimplementation of the built-in property."""
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.__doc__ = doc or (fget.__doc__ if fget else None)
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.fget is None:
raise AttributeError(f"property '{self.name}' has no getter")
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError(f"property '{self.name}' is read-only")
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError(f"property '{self.name}' cannot be deleted")
self.fdel(obj)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)
Now you understand why @property is more than magic — it's just a descriptor.
How @classmethod and @staticmethod Work¶
Both are descriptors that change how the function is bound.
class classmethod:
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
if objtype is None:
objtype = type(obj)
def wrapper(*args, **kwargs):
return self.func(objtype, *args, **kwargs)
return wrapper
class staticmethod:
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
return self.func # just return the raw function
Regular methods are also descriptors — function.__get__ binds self:
class Foo:
def bar(self):
pass
print(type(Foo.__dict__['bar'])) # <class 'function'>
print(Foo.bar) # <function Foo.bar>
f = Foo()
print(f.bar) # <bound method Foo.bar of <Foo object>>
# This is function.__get__(f, Foo) returning a bound method
Descriptors and __slots__¶
How descriptors interact with slot-based storage.
When a class uses __slots__, Python creates member descriptors for each slot:
class Compact:
__slots__ = ('x', 'y')
print(type(Compact.x)) # <class 'member_descriptor'>
c = Compact()
c.x = 10
print(c.x) # 10
# No __dict__ exists
try:
c.__dict__
except AttributeError:
print("No __dict__ — slots only")
Member descriptors are data descriptors implemented in C — extremely fast.
Composing Descriptors (Stacking Validators)¶
Chain validators by layering descriptors.
class Validator:
"""Base class for chainable validators."""
def __init__(self):
self.validators = []
def __set_name__(self, owner, name):
self.name = name
def add_validator(self, func):
self.validators.append(func)
return self
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
for validate in self.validators:
validate(self.name, value)
obj.__dict__[self.name] = value
def typed(expected):
def check(name, value):
if not isinstance(value, expected):
raise TypeError(f"{name}: expected {expected.__name__}, got {type(value).__name__}")
return check
def positive(name, value):
if value <= 0:
raise ValueError(f"{name}: must be positive, got {value}")
class Product:
name = Validator()
price = Validator()
name.add_validator(typed(str))
price.add_validator(typed((int, float)))
price.add_validator(positive)
def __init__(self, name, price):
self.name = name
self.price = price
p = Product("Widget", 9.99)
print(p.name, p.price) # Widget 9.99
try:
Product("Free", -5)
except ValueError as ex:
print(ex) # price: must be positive, got -5
Descriptor Best Practices¶
Keep them focused, use __set_name__, and prefer property for simple cases.
Rules of thumb
- Use
__set_name__— don't require the user to pass the field name manually - Store data in
obj.__dict__— not in the descriptor itself (otherwise all instances share one value) - Return
selfwhenobj is None— allows class-level introspection - Make it a data descriptor if you need to validate writes — add
__set__ - Make it a non-data descriptor for caching — allows instance dict to override
Real-World Descriptor Usage¶
ORMs, form fields, and settings use descriptors for validated attributes.
| Library/Framework | What uses descriptors |
|---|---|
| SQLAlchemy | Column() mapped attributes |
| Django ORM | Field() model fields |
| Pydantic v1 | Field() validators |
| attrs / dataclasses | Generated __init__ uses __set_name__ |
functools.cached_property | Non-data descriptor for caching |
Built-in property | Data descriptor |
Built-in classmethod | Non-data descriptor |
Built-in staticmethod | Non-data descriptor |
| Functions | function.__get__ creates bound methods |
Practice Exercises¶
- Write a
Positivedescriptor that only allows positive numbers, raisingValueErrorotherwise. - Implement
@cached_propertyfrom scratch as a non-data descriptor. - Write a
ReadOnlydescriptor that allows setting once in__init__but raisesAttributeErroron subsequent writes. - Write an
Auditeddescriptor that keeps a history of all values ever assigned, accessible viaMyClass.field.history(obj). - Reimplement
@propertywith fullgetter,setter,deleterchaining. - Build an ORM-style
Columndescriptor that tracks dirty fields for SQL UPDATE generation.
💬 Discussion
Have a question about this topic? Found an error? Share your thoughts below.