Chapter 15 — Properties, Descriptors, and Managed Attributes
1. Opening problem
A property appears simple:
class User:
@property
def email(self) -> str:
return self._email
But why does attribute syntax call a method?
user.email
The answer is the descriptor protocol.
Descriptors are objects that control attribute access through methods such as:
__get__;__set__;__delete__;__set_name__.
Properties, methods, class methods, static methods, ORM fields, validators, and many framework features rely on descriptors.
This chapter explains how managed attributes actually work.
2. Attribute access is behavior
When Python evaluates:
instance.attribute
it does not simply read a dictionary.
Lookup may involve:
- data descriptors;
- instance attributes;
- non-data descriptors;
- class attributes;
- base classes;
__getattr__;__getattribute__.
This is why the same syntax can represent:
- stored data;
- computed data;
- validation;
- method binding;
- lazy loading;
- ORM access.
3. Properties
class Temperature:
def __init__(
self,
celsius: float,
) -> None:
self.celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(
self,
value: float,
) -> None:
if value < -273.15:
raise ValueError(
"Below absolute zero"
)
self._celsius = float(value)
Caller:
temperature.celsius
temperature.celsius = 20
A property preserves attribute syntax while adding behavior.
Use properties when:
- validation is needed;
- a value is computed;
- storage representation changes;
- backward-compatible attribute behavior matters.
Avoid heavy I/O in properties. Callers expect attribute access to be cheap.
4. Read-only computed properties
class Rectangle:
def __init__(
self,
width: float,
height: float,
) -> None:
self.width = width
self.height = height
@property
def area(self) -> float:
return (
self.width
* self.height
)
No setter exists.
This does not create absolute immutability. The underlying dimensions can still change.
Properties communicate API intent, not security.
5. Cached properties
from functools import cached_property
class Report:
@cached_property
def rendered_html(
self,
) -> str:
return expensive_render()
The first access calculates and stores the result.
Later access returns the cached value.
Use when:
- calculation is expensive;
- result remains valid for the object's lifetime;
- memory cost is acceptable;
- thread behavior is understood.
Avoid when source state can change without cache invalidation.
6. Descriptor protocol
A descriptor is an object stored on a class and implementing one or more descriptor methods.
class Descriptor:
def __get__(
self,
instance,
owner,
):
...
def __set__(
self,
instance,
value,
) -> None:
...
def __delete__(
self,
instance,
) -> None:
...
Example class:
class User:
email = Descriptor()
Access:
user.email
may invoke Descriptor.__get__.
Assignment:
user.email = "..."
may invoke Descriptor.__set__.
7. Data and non-data descriptors
A data descriptor defines __set__ or __delete__, usually also __get__.
A non-data descriptor defines only __get__.
Lookup priority differs.
Simplified order:
- data descriptor on class;
- instance dictionary;
- non-data descriptor or class attribute;
__getattr__.
This explains why properties with setters can override instance dictionary entries.
Methods are non-data descriptors. An instance attribute can shadow a method name.
8. A basic validation descriptor
class Positive:
def __set_name__(
self,
owner,
name: str,
) -> None:
self._name = name
self._storage_name = (
f"_{name}"
)
def __get__(
self,
instance,
owner,
):
if instance is None:
return self
return getattr(
instance,
self._storage_name,
)
def __set__(
self,
instance,
value: float,
) -> None:
if value <= 0:
raise ValueError(
f"{self._name} "
"must be positive"
)
setattr(
instance,
self._storage_name,
float(value),
)
Usage:
class Rectangle:
width = Positive()
height = Positive()
def __init__(
self,
width: float,
height: float,
) -> None:
self.width = width
self.height = height
The descriptor instance is shared at the class level, but values are stored per instance.
9. Why __set_name__ matters
Without __set_name__, the descriptor would need the attribute name manually:
width = Positive("width")
__set_name__ is called when the owner class is created:
descriptor.__set_name__(
Rectangle,
"width",
)
This supports reusable declarative fields.
Frameworks use the same idea for:
- ORM columns;
- form fields;
- schema fields;
- dependency declarations;
- validation rules.
10. Class-level access
Descriptor __get__ receives instance=None when accessed through the class:
Rectangle.width
A common pattern:
if instance is None:
return self
This allows introspection of the descriptor itself.
Alternative descriptors may return metadata or another object.
11. Avoid storing values on the descriptor
Incorrect:
class Positive:
def __set__(
self,
instance,
value,
):
self.value = value
The descriptor belongs to the class and is shared across all instances.
Every object would overwrite the same descriptor state.
Store values:
- in the instance dictionary;
- in a weak-key dictionary;
- in another per-instance storage mechanism.
12. Weak-reference storage
from weakref import WeakKeyDictionary
class Positive:
def __init__(self) -> None:
self._values = (
WeakKeyDictionary()
)
def __get__(
self,
instance,
owner,
):
if instance is None:
return self
return self._values[
instance
]
def __set__(
self,
instance,
value,
) -> None:
if value <= 0:
raise ValueError(
"Expected positive value"
)
self._values[
instance
] = value
Weak keys allow instance entries to disappear when instances are collected.
Trade-offs:
- instances must support weak references;
- hashing and equality behavior matter;
- storage is less transparent;
- instance dictionary storage is usually simpler.
13. Methods are descriptors
A function stored on a class implements descriptor behavior.
class Greeter:
def greet(
self,
name: str,
) -> str:
return f"Hello, {name}"
Access through class:
Greeter.greet
Access through instance:
greeter.greet
The function's descriptor __get__ creates a bound method containing:
- the function;
- the instance.
This is why self is automatically supplied during normal method calls.
14. Building a simple method descriptor
from types import MethodType
class FunctionLike:
def __init__(
self,
function,
) -> None:
self._function = function
def __get__(
self,
instance,
owner,
):
if instance is None:
return self._function
return MethodType(
self._function,
instance,
)
This demonstrates the principle behind method binding.
Real Python function objects implement this internally.
15. classmethod and staticmethod
classmethod is a descriptor that binds the class instead of the instance.
class User:
@classmethod
def create(cls):
return cls()
staticmethod returns the underlying function without binding either instance or class.
These are not special parser features. They are descriptor-based wrappers.
16. Descriptor inheritance and reuse
A reusable descriptor can be placed on a base class:
class Person:
name = NonEmptyString()
Subclasses inherit the descriptor.
Be careful when descriptor configuration is mutable. A shared descriptor may hold owner-specific metadata.
__set_name__ may be called for each class where the descriptor appears directly, but inherited descriptors require careful design.
When reuse becomes complex, prefer explicit per-class instances.
17. Validation framework example
class Field:
def __init__(
self,
*,
validator,
normalizer=lambda value: value,
) -> None:
self._validator = validator
self._normalizer = normalizer
def __set_name__(
self,
owner,
name: str,
) -> None:
self._name = name
self._storage_name = (
f"_{name}"
)
def __get__(
self,
instance,
owner,
):
if instance is None:
return self
return getattr(
instance,
self._storage_name,
)
def __set__(
self,
instance,
value,
) -> None:
normalized = (
self._normalizer(value)
)
if not self._validator(
normalized
):
raise ValueError(
f"Invalid value for "
f"{self._name}"
)
setattr(
instance,
self._storage_name,
normalized,
)
Usage:
class User:
email = Field(
validator=lambda value: (
isinstance(value, str)
and "@" in value
),
normalizer=lambda value: (
value.strip().lower()
),
)
This is educational. Production validation frameworks must consider:
- error types;
- type annotations;
- inheritance;
- defaults;
- missing values;
- serialization;
- thread safety;
- introspection.
18. Properties versus descriptors
Use a property when:
- one class needs one managed attribute;
- logic is specific to that class;
- readability is more important than reuse.
Use a custom descriptor when:
- the same attribute behavior appears across many classes;
- declarative class syntax adds value;
- framework-like field metadata is needed;
- central behavior must be reusable.
Do not create a descriptor for one trivial property.
19. __getattr__ versus descriptors
__getattr__ handles missing attributes dynamically:
class Settings:
def __getattr__(
self,
name: str,
):
...
Descriptors define known managed attributes on the class.
Descriptors provide:
- discoverability;
- class metadata;
- stronger static structure;
- per-field behavior.
__getattr__ is useful for truly dynamic namespaces but weakens typing and error detection.
20. Deletion
A descriptor may implement:
def __delete__(
self,
instance,
) -> None:
...
Example:
class RequiredField:
def __delete__(
self,
instance,
) -> None:
raise AttributeError(
"Required field cannot be deleted"
)
Deletion semantics should be explicit.
Do not support deletion automatically if it violates invariants.
21. TypeScript comparison
TypeScript supports getters and setters:
class User {
private _email: string;
get email(): string {
return this._email;
}
set email(value: string) {
this._email = value;
}
}
Python properties provide similar syntax.
Descriptors go further by making reusable attribute-management objects possible.
TypeScript decorators can simulate field management, but Python descriptors are part of the core runtime lookup model.
22. Common mistakes
Heavy I/O in properties
Use explicit methods for expensive work.
Descriptor stores shared value
Store per-instance data.
Descriptor overengineering
A property may be enough.
Dynamic attributes everywhere
Tooling and clarity suffer.
Hidden lazy database loading
Document and control expensive attribute access.
Recursive __setattr__
Use descriptor storage or object.__setattr__ carefully.
Weak-reference assumptions
Not every object supports weak references.
23. English vocabulary
| Term | Meaning |
|---|---|
| descriptor | object controlling attribute access |
| managed attribute | attribute whose access invokes behavior |
| data descriptor | descriptor defining assignment or deletion |
| non-data descriptor | descriptor defining only reading |
| binding | association of function and instance or class |
| lazy loading | loading a value only when accessed |
| backing field | internal storage behind a property |
| introspection | examining class or field metadata |
| weak reference | reference not preventing object collection |
| lookup precedence | order in which attribute sources are checked |
Useful sentences:
- “The property preserves attribute syntax while adding validation.”
- “The descriptor stores values on each instance.”
- “Methods become bound through non-data descriptor behavior.”
- “Class-level access returns the descriptor for introspection.”
- “This property performs hidden I/O and should become a method.”
- “The descriptor's shared state would leak across instances.”
24. Speaking task
Explain for nine minutes:
Why methods and properties are both examples of descriptor behavior.
25. Writing task
Write a 400-word review of an ORM-like model where every attribute access may trigger a database query without documentation or caching.
26. Exercises
Exercise 1
Implement a reusable NonEmptyString descriptor.
Exercise 2
Implement a BoundedNumber descriptor with minimum and maximum values.
Exercise 3
Demonstrate that methods can be shadowed by instance attributes because they are non-data descriptors.
Exercise 4
Create a read-only descriptor that computes a value from instance state.
Exercise 5
Choose between a property and descriptor for five different scenarios.
27. Complete solutions
Solution 1
class NonEmptyString:
def __set_name__(
self,
owner,
name: str,
) -> None:
self._name = name
self._storage_name = (
f"_{name}"
)
def __get__(
self,
instance,
owner,
):
if instance is None:
return self
return getattr(
instance,
self._storage_name,
)
def __set__(
self,
instance,
value: str,
) -> None:
if not isinstance(
value,
str,
):
raise TypeError(
f"{self._name} "
"must be a string"
)
normalized = value.strip()
if not normalized:
raise ValueError(
f"{self._name} "
"must not be blank"
)
setattr(
instance,
self._storage_name,
normalized,
)
Solution 2
class BoundedNumber:
def __init__(
self,
*,
minimum: float,
maximum: float,
) -> None:
if minimum > maximum:
raise ValueError(
"minimum exceeds maximum"
)
self._minimum = minimum
self._maximum = maximum
def __set_name__(
self,
owner,
name: str,
) -> None:
self._name = name
self._storage_name = (
f"_{name}"
)
def __get__(
self,
instance,
owner,
):
if instance is None:
return self
return getattr(
instance,
self._storage_name,
)
def __set__(
self,
instance,
value: float,
) -> None:
numeric = float(value)
if not (
self._minimum
<= numeric
<= self._maximum
):
raise ValueError(
f"{self._name} outside range"
)
setattr(
instance,
self._storage_name,
numeric,
)
Solution 3
class Example:
def greet(self) -> str:
return "Hello"
example = Example()
print(example.greet())
example.greet = "shadowed"
print(example.greet)
The instance attribute shadows the non-data descriptor method.
A property with a setter is a data descriptor and has higher lookup priority.
Solution 4
class Area:
def __get__(
self,
instance,
owner,
):
if instance is None:
return self
return (
instance.width
* instance.height
)
class Rectangle:
area = Area()
def __init__(
self,
width: float,
height: float,
) -> None:
self.width = width
self.height = height
Solution 5
Use a property for class-specific email normalization.
Use a descriptor for the same validated field across many model classes.
Use a method for a value requiring remote I/O.
Use cached_property for an expensive stable calculation.
Use an ordinary public attribute when no behavior is needed.
28. Chapter checkpoint
You should now be able to explain:
- properties;
- descriptor protocol;
- data versus non-data descriptors;
- lookup priority;
__set_name__;- per-instance storage;
- method binding;
- classmethod and staticmethod descriptors;
- descriptors versus properties;
- hidden-I/O risks.