Zum Hauptinhalt springen

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:

  1. data descriptor on class;
  2. instance dictionary;
  3. non-data descriptor or class attribute;
  4. __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

TermMeaning
descriptorobject controlling attribute access
managed attributeattribute whose access invokes behavior
data descriptordescriptor defining assignment or deletion
non-data descriptordescriptor defining only reading
bindingassociation of function and instance or class
lazy loadingloading a value only when accessed
backing fieldinternal storage behind a property
introspectionexamining class or field metadata
weak referencereference not preventing object collection
lookup precedenceorder 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:

  1. properties;
  2. descriptor protocol;
  3. data versus non-data descriptors;
  4. lookup priority;
  5. __set_name__;
  6. per-instance storage;
  7. method binding;
  8. classmethod and staticmethod descriptors;
  9. descriptors versus properties;
  10. hidden-I/O risks.