Introduction
Now that you understand the descriptor protocol and the data vs non-data distinction, it is time to build real-world descriptors. This lesson covers validated fields, cached properties, type-checked attributes, and ORM-like field definitions -- the same patterns used by Django, SQLAlchemy, and attrs.
Key Concepts
- Validated fields -- Descriptors that enforce constraints (range, format, type) on every assignment.
- Cached properties -- Non-data descriptors that compute once and store the result on the instance.
- Type-checked attributes -- Descriptors that reject values of the wrong type at assignment time.
- ORM-like fields -- Descriptors combined with
__init_subclass__to build declarative APIs where class attributes define a schema.
Real World Context
- Django model fields (
CharField(max_length=100)) are descriptors that validate data before it reaches the database. - SQLAlchemy's
mapped_column()uses descriptors to map Python attributes to database columns. - The
attrsandpydanticlibraries use descriptor-like patterns (via__set_attr__or model validators) for declarative data classes. - Python's own
propertyis a descriptor that provides getter/setter/deleter with a clean syntax.
Deep Dive
Pattern 1: Validated Field Descriptor
A generic descriptor that accepts a validation function:
pythonclass ValidatedField: """Descriptor with pluggable validation.""" def __init__(self, *validators): self.validators = validators 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): for validator in self.validators: validator(self.name, value) # Raises on failure obj.__dict__[self.name] = value # Reusable validators def positive(name, value): if value <= 0: raise ValueError(f"{name} must be positive, got {value}") def max_length(limit): def check(name, value): if len(value) > limit: raise ValueError(f"{name} exceeds max length {limit}") return check def one_of(*choices): def check(name, value): if value not in choices: raise ValueError(f"{name} must be one of {choices}") return check class Product: name = ValidatedField(max_length(50)) price = ValidatedField(positive) category = ValidatedField(one_of("electronics", "books", "clothing")) def __init__(self, name, price, category): self.name = name self.price = price self.category = category p = Product("Laptop", 999.99, "electronics") # OK # Product("Laptop", -10, "electronics") # ValueError # Product("Laptop", 999, "food") # ValueError
Pattern 2: Cached Property (Manual Implementation)
This mirrors functools.cached_property to show how it works under the hood:
pythonclass CachedProperty: """Non-data descriptor that caches the result on the instance.""" def __init__(self, func): self.func = func self.name = func.__name__ self.__doc__ = func.__doc__ def __get__(self, obj, objtype=None): if obj is None: return self # Compute and store directly in instance dict value = self.func(obj) obj.__dict__[self.name] = value return value class Report: def __init__(self, data): self.data = data @CachedProperty def summary(self): """Expensive aggregation.""" print("Computing summary...") return { "count": len(self.data), "total": sum(self.data), "mean": sum(self.data) / len(self.data), } r = Report([10, 20, 30, 40, 50]) print(r.summary) # Computing summary... print(r.summary) # cached, no recomputation
Because CachedProperty is a non-data descriptor (no __set__), the obj.__dict__[self.name] = value line stores the result where Python looks before falling back to the descriptor. The descriptor is never called again.
Pattern 3: Type-Checked Attributes
pythonclass Typed: """Data descriptor that enforces a specific type.""" def __init__(self, expected_type, *, default=None): self.expected_type = expected_type self.default = default 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, self.default) def __set__(self, obj, value): if not isinstance(value, self.expected_type): raise TypeError( f"{self.name}: expected {self.expected_type.__name__}, " f"got {type(value).__name__}" ) obj.__dict__[self.name] = value class Config: host = Typed(str, default="localhost") port = Typed(int, default=8080) debug = Typed(bool, default=False) def __init__(self, **kwargs): for key, value in kwargs.items(): setattr(self, key, value) c = Config(host="db.example.com", port=5432, debug=True) print(c.host) # db.example.com print(c.port) # 5432 # Config(port="not-a-number") # TypeError
Pattern 4: ORM-Like Field Definitions
Combine descriptors with __init_subclass__ to build a mini declarative ORM:
pythonclass Field: """Descriptor acting as an ORM column definition.""" def __init__(self, field_type, required=True, default=None): self.field_type = field_type self.required = required self.default = default 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, self.default) def __set__(self, obj, value): if value is not None and not isinstance(value, self.field_type): raise TypeError( f"{self.name}: expected {self.field_type.__name__}" ) obj.__dict__[self.name] = value class Model: """Base class that auto-generates __init__ from Field descriptors.""" def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) cls._fields = { name: attr for name, attr in vars(cls).items() if isinstance(attr, Field) } def __init__(self, **kwargs): for name, field in self.__class__._fields.items(): if name in kwargs: setattr(self, name, kwargs[name]) elif field.required and field.default is None: raise ValueError(f"Missing required field: {name}") else: setattr(self, name, field.default) def __repr__(self): attrs = ', '.join( f"{n}={getattr(self, n)!r}" for n in self.__class__._fields ) return f"{self.__class__.__name__}({attrs})" class User(Model): name = Field(str) email = Field(str) age = Field(int, required=False, default=0) u = User(name="Alice", email="alice@example.com") print(u) # User(name='Alice', email='alice@example.com', age=0) print(u.age) # 0 u.age = 25 print(u.age) # 25 # User(name="Alice") # ValueError: Missing required field: email
Common Pitfalls
- Overcomplicating simple use cases. If you only need a getter and setter for one attribute, use
@property. Descriptors shine when the same logic must apply to many attributes across many classes. - Forgetting
__set_name__. Without it, every field instantiation requires a redundant name string argument. - Not handling
Noneor default values. Make sure your validators handleNoneif the field is optional. - Mutable defaults. If your descriptor has a mutable default (like a list), each instance will share the same object. Use a factory pattern instead:
default_factory=list.
Best Practices
- Build small, composable validators and combine them with a generic
ValidatedFielddescriptor. - Use non-data descriptors for caching patterns and data descriptors for validation patterns.
- Combine descriptors with
__init_subclass__for declarative APIs that feel Pythonic. - Write thorough tests for edge cases:
Nonevalues, wrong types, missing required fields, and class-level access (MyClass.attr).
Summary
Practical descriptors follow a few recurring patterns: validated fields (pluggable validators on a data descriptor), cached properties (non-data descriptors that store results in the instance dict), type-checked attributes (data descriptors that enforce isinstance checks), and ORM-like fields (descriptors combined with __init_subclass__ for declarative class definitions). These patterns are the building blocks of Python frameworks like Django, SQLAlchemy, attrs, and pydantic.
Code Examples
class ValidatedField:
"""Generic descriptor with pluggable validators."""
def __init__(self, *validators):
self.validators = validators
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):
for validator in self.validators:
validator(self.name, value)
obj.__dict__[self.name] = value
def between(low, high):
def check(name, value):
if not (low <= value <= high):
raise ValueError(f"{name} must be between {low} and {high}")
return check
def non_empty(name, value):
if not value:
raise ValueError(f"{name} must not be empty")
class StudentGrade:
name = ValidatedField(non_empty)
score = ValidatedField(between(0, 100))
def __init__(self, name, score):
self.name = name
self.score = score
g = StudentGrade("Alice", 95) # OK
print(g.name, g.score) # Alice 95
# StudentGrade("", 95) # ValueError: name must not be empty
# StudentGrade("Alice", 150) # ValueError: score must be between 0 and 100class Field:
"""ORM-like field descriptor."""
def __init__(self, field_type, required=True, default=None):
self.field_type = field_type
self.required = required
self.default = default
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, self.default)
def __set__(self, obj, value):
if value is not None and not isinstance(value, self.field_type):
raise TypeError(f"{self.name}: expected {self.field_type.__name__}")
obj.__dict__[self.name] = value
class Model:
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls._fields = {
k: v for k, v in vars(cls).items() if isinstance(v, Field)
}
def __init__(self, **kwargs):
for name, field in self._fields.items():
if name in kwargs:
setattr(self, name, kwargs[name])
elif field.required and field.default is None:
raise ValueError(f"Missing required field: {name}")
def __repr__(self):
parts = ', '.join(f"{n}={getattr(self, n)!r}" for n in self._fields)
return f"{type(self).__name__}({parts})"
class BlogPost(Model):
title = Field(str)
body = Field(str)
views = Field(int, required=False, default=0)
post = BlogPost(title="Descriptors", body="They are powerful")
print(post) # BlogPost(title='Descriptors', body='They are powerful', views=0)