Property Descriptors
Deep dive · part of Python OOP Advanced
A descriptor is any object that implements __get__, __set__ or __delete__. They power property, classmethod, staticmethod and ORM fields. Use them to centralise validation or computed attributes.
Descriptors implement the attribute protocol: objects with __get__, __set__, or __delete__ control access on host instances. property, classmethod, and staticmethod are descriptors; ORMs use them for fields and validators.
Writing your own descriptor centralizes validation (non-negative balance) or lazy computation (parse once) instead of scattering checks in every setter or method.
Production code combines this topic with logging, tests, and clear module boundaries so refactors stay safe when requirements grow.
__set_name__(owner, name) runs at class creation to stash the private attribute name.
__get__(self, obj, objtype=None) returns the value; obj None means access on the class.
__set__ raises AttributeError if the descriptor is data-descriptor (defines __set__).
Non-data descriptors (only __get__) can be shadowed by instance __dict__ entries.
property is a data descriptor wrapping getter/setter/deleter callables.
cached_property stores computed values on instance __dict__ after first access.
Descriptor invocation order: data descriptors on the class beat instance __dict__; non-data descriptors lose to instance attributes. That is why @property on a class can be overridden unless you use a data descriptor with __set__.
Reuse descriptors across classes by parameterizing behavior in __init__ (Positive, BoundedInt) rather than subclassing per field.
For typing, Generic descriptors are advanced; many teams stick to property for simple cases.
Pydantic and attrs validators cover many DTO cases—custom descriptors remain for framework hooks and hot paths.
Reach for @property first; promote to descriptor classes when three or more fields share validation.
Read the parent tutorial on pythondeck.com for runnable snippets, then reproduce them locally in a virtual environment with pinned dependency versions matching your deployment target.
When pairing with teammates, agree on one idiomatic pattern per concern—mixed styles in one repo slow reviews and invite subtle integration bugs during merges.
Forgetting self.name = '_' + name in __set_name__, colliding fields across classes.
Infinite recursion in property setters (self.x = x instead of backing store).
Storing mutable class-level defaults shared by all instances.
Using descriptors on module-level functions—they only work on classes.
Reach for @property first; extract a descriptor when three+ fields share rules.
Keep validation errors specific (ValueError with field name).
Test descriptor classes in isolation with a minimal host class.
Document whether None is allowed before production data arrives.
Re-read the examples below with these ideas in mind; change variable names and inputs to match your own project.
The program below demonstrates validating descriptor. Read the comments on each line, run the code, then change names or values to see how the output shifts.
# Example: Validating descriptor
# Run in the REPL or save as a .py file and execute with python.
class Positive:
def __set_name__(self, owner, name):
self.name = "_" + name
def __get__(self, obj, objtype=None):
return getattr(obj, self.name)
def __set__(self, obj, value):
if value < 0:
raise ValueError("must be positive")
setattr(obj, self.name, value)
class Account:
balance = Positive()
def __init__(self, b):
self.balance = b
a = Account(100)
print(a.balance)
# a.balance = -10 # ValueError
This sample walks through lazy attribute in a small, runnable script. Paste it into the REPL or save it as a .py file before you continue to the next block.
# Example: Lazy attribute
# Run in the REPL or save as a .py file and execute with python.
class lazy:
def __init__(self, fn): self.fn = fn
def __set_name__(self, owner, name): self.name = name
def __get__(self, obj, objtype=None):
value = self.fn(obj)
obj.__dict__[self.name] = value # bypass descriptor next time
return value
class Doc:
@lazy
def parsed(self):
print("parsing once...")
return list("expensive")
d = Doc()
print(d.parsed); print(d.parsed)
Here is a hands-on illustration of validating descriptor. Follow the inline comments first; only then execute the snippet and compare the result with what you expected.
# Descriptors implement __get__/__set__ to control attribute access
class Positive: # descriptor class
def __set_name__(self, owner, name): # record name
self.name = "_" + name # private storage attr
def __get__(self, obj, objtype=None): # read
return getattr(obj, self.name) # fetch stored
def __set__(self, obj, value): # write
if value < 0: # validation
raise ValueError("must be positive") # reject
setattr(obj, self.name, value) # store
class Account: # user of descriptor
balance = Positive() # managed field
def __init__(self, b): self.balance = b # init
print(Account(10).balance) # 10
The program below demonstrates lazy descriptor. Read the comments on each line, run the code, then change names or values to see how the output shifts.
# Descriptor can compute once then store in __dict__
class lazy: # lazy attribute descriptor
def __init__(self, fn): self.fn = fn # factory function
def __set_name__(self, owner, name): self.name = name # attr name
def __get__(self, obj, objtype=None): # on access
val = self.fn(obj) # compute
obj.__dict__[self.name] = val # cache on instance
return val # return cached
class Doc: # host class
@lazy # apply descriptor
def parsed(self): # expensive
print("parse") ; return [1, 2, 3] # body
d = Doc(); print(d.parsed, d.parsed) # parse printed once
Related deep dives on Python OOP Advanced: