Descriptors and __set_name__
How property works — the __get__/__set__ protocol and data/non-data descriptors, and clean validation fields with __set_name__.
In Chapter 15 magic methods in depth and protocols we saw __getattr__ / __setattr__ as hooks that intercept attribute access. One step further, the tool that turns the attribute itself into an object is the descriptor. @property, @classmethod, @staticmethod, ORM Column, and dataclass fields are all built on top of descriptors.
This chapter pairs with the next, Chapter 17 metaclasses — when do you really need them?. Both share the common ground of being “tools for library/framework authors,” and Chapter 17 makes explicit the division of when to choose which.
What is a descriptor? #
A descriptor is an instance of a class that defines at least one of __get__, __set__, __delete__. Place one as a class attribute, and accessing that attribute automatically calls the descriptor method.
class Constant:
def __init__(self, value):
self.value = value
def __get__(self, instance, owner):
return self.value
class Config:
PI = Constant(3.14)
c = Config()
print(c.PI) # 3.14 — Constant.__get__() is called
print(Config.PI) # 3.14 — same method, instance is NoneA simple attribute access like c.PI actually unfolds as a method call. A descriptor can run custom code at that point.
__get__ signature
#
def __get__(self, instance, owner): ...self— the descriptor instanceinstance— the object accessing the attribute.Nonewhen accessed via the class itselfowner— the class that owns the attribute
Data vs non-data descriptors #
This is the most confusing part of descriptors.
| Kind | Definition | Priority |
|---|---|---|
| Data descriptor | has __set__ or __delete__ | takes precedence over instance dict |
| Non-data descriptor | only __get__ | instance dict takes precedence |
The actual difference:
class DataDesc:
def __get__(self, instance, owner):
return "from descriptor"
def __set__(self, instance, value):
pass
class A:
x = DataDesc()
a = A()
a.__dict__["x"] = "from instance dict"
print(a.x) # 'from descriptor' ← descriptor winsclass NonDataDesc:
def __get__(self, instance, owner):
return "from descriptor"
class B:
x = NonDataDesc()
b = B()
b.__dict__["x"] = "from instance dict"
print(b.x) # 'from instance dict' ← instance dict winsWhy this priority matters:
- Methods are non-data descriptors. Putting an attribute of the same name on the instance wins (allows method overrides).
@propertyis a data descriptor. Even if a user creates an instance attribute with the same name, the property always runs.
@property is actually a descriptor
#
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.14 * self.radius ** 2
c = Circle(5)
print(c.area) # 78.5 — area.__get__() is calledThe @property decorator builds an instance of the property descriptor class. Circle.area isn’t a function — it’s a property object. Building it ourselves:
class my_property:
def __init__(self, fget):
self.fget = fget
def __get__(self, instance, owner):
if instance is None:
return self
return self.fget(instance)
class Circle:
def __init__(self, radius):
self.radius = radius
@my_property
def area(self):
return 3.14 * self.radius ** 2
c = Circle(5)
print(c.area) # 78.5The standard library property adds setter/deleter on top.
User-defined — validation descriptor #
A common pattern is a descriptor that validates an attribute.
class PositiveInt:
def __init__(self):
self.value = 0 # ✗ all instances share the same descriptor
def __get__(self, instance, owner):
return self.value
def __set__(self, instance, value):
if value < 0:
raise ValueError("negative not allowed")
self.value = valueProblem: One PositiveInt() descriptor instance is shared across all use sites. Two User instances would have the same age.
Fix: store values in the instance dict.
class PositiveInt:
def __set_name__(self, owner, name):
self.name = "_" + name # backing field name
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.name)
def __set__(self, instance, value):
if value < 0:
raise ValueError(f"{self.name[1:]} cannot be negative")
setattr(instance, self.name, value)
class User:
age = PositiveInt()
score = PositiveInt()
u = User()
u.age = 30 # OK
u.age = -1 # ✗ ValueError
u.score = 95 # different instances can hold different values__set_name__ — auto-name (3.6+)
#
The trick in the code above is __set_name__.
def __set_name__(self, owner, name): ...This method is called automatically when the class is defined.
owner— the class containing the descriptor (User)name— the attribute name the descriptor is bound to (age,score)
Before 3.6 this hook didn’t exist — you had to pass the name manually.
class User:
age = PositiveInt("age") # name duplicated — DRY violation
score = PositiveInt("score")__set_name__ removes the duplication. Users just write PositiveInt().
More refined validation — generalized #
Generalize this into a multi-type validator descriptor.
class Validator:
def __set_name__(self, owner, name):
self.private_name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.private_name)
def __set__(self, instance, value):
self.validate(value)
setattr(instance, self.private_name, value)
def validate(self, value):
raise NotImplementedError # subclasses implement
class IntRange(Validator):
def __init__(self, lo, hi):
self.lo, self.hi = lo, hi
def validate(self, value):
if not isinstance(value, int):
raise TypeError(f"int required, got {type(value).__name__}")
if not (self.lo <= value <= self.hi):
raise ValueError(f"out of range {self.lo} ~ {self.hi}")
class StringLen(Validator):
def __init__(self, min_len, max_len):
self.min_len, self.max_len = min_len, max_len
def validate(self, value):
if not isinstance(value, str):
raise TypeError("str required")
if not (self.min_len <= len(value) <= self.max_len):
raise ValueError("length out of range")
class User:
age = IntRange(0, 150)
name = StringLen(1, 50)
u = User()
u.age = 30 # OK
u.age = 999 # ValueError
u.name = "" # ValueErrorThe same pattern lets you build regex validation, email format validation, and so on as descriptors. Chapter 24 Pydantic v2 in depth in Part 4 can be seen as the industrial-grade outcome of this pattern.
Descriptor vs @property — when which?
#
| Spot | Fit |
|---|---|
| One or two validations in a single class | @property (simple) |
| Same validation pattern across many classes / fields | descriptor (reuse) |
| Simple computation (read-only) | @property or @cached_property |
| ORM columns, form fields — bundling metadata | descriptor |
Most everyday code is solved with @property. The places to write descriptors yourself are library/framework code.
@cached_property — a common form
#
Already in the standard library.
from functools import cached_property
class Document:
def __init__(self, text):
self.text = text
@cached_property
def word_count(self):
print("computing...")
return len(self.text.split())
d = Document("a b c d")
print(d.word_count) # computing... 4
print(d.word_count) # 4 (cached — no recomputation)Compute once on first access, store in instance dict. Subsequent accesses act like a regular attribute. @property differs by recomputing every time.
cached_property is a non-data descriptor. After the first computation, the value stored in the instance dict takes precedence and the descriptor isn’t called again.
__get__ lookup flow — the full picture
#
Order Python uses for obj.attr:
- Data descriptor (on class, has
__set__or__delete__) → call - Instance dict → return as-is
- Non-data descriptor / class attribute → call if method, else return
__getattr__→ fallbackAttributeError→ all failed
This priority is the source of the data vs non-data difference shown above.
__delete__ — deletion hook
#
class Locked:
def __get__(self, instance, owner):
return getattr(instance, "_value", None)
def __set__(self, instance, value):
instance._value = value
def __delete__(self, instance):
raise AttributeError("deletion forbidden")
class Box:
x = Locked()
b = Box()
b.x = 5
del b.x # AttributeErrorNot used often, but useful for cleanup/rollback timing.
Slots and descriptors #
__slots__ from Chapter 8 dataclass and __slots__ actually turns each slot into a descriptor.
class Point:
__slots__ = ("x", "y")
print(Point.x) # <member 'x' of 'Point' objects>
print(type(Point.x).__set__ is not None) # True (data descriptor)This is why __slots__ objects allow attribute access/assignment without __dict__.
Where descriptors appear in libraries — table #
| Library/tool | Descriptor role |
|---|---|
| Built-in | property, classmethod, staticmethod |
functools | cached_property |
dataclasses | field() (some compare/factory metadata) |
ORM (SQLAlchemy, Django ORM) | Column, ForeignKey, model fields |
Forms/validation (WTForms, Django forms) | field classes |
attrs, Pydantic | field definitions |
When reading library code, “why is this class variable behaving magically?” — it’s almost always a descriptor. Chapter 25 DB integration — SQLAlchemy 2.x + Alembic ORM model definitions are the same spot.
Exercises #
- Write a
PositiveInt()validation descriptor. Use__set_name__to receive the name and store on the_<name>backing field. Defineclass User: age = PositiveInt(); score = PositiveInt(), then verify that two instances hold different values and thatu.age = -1raisesValueError. - Build a
Validatorbase and writeEmailField(Validator)on top of it. Invalidate(value), verify the email format with a regex. Withclass Account: email = EmailField(), validation runs on invalid values. - Build
@cached_propertyyourself. On the first call, compute, then store on the instance__dict__under the same name. From the second call on, the descriptor is skipped and the instance attribute is returned directly. Verify the behavior matches the standardfunctools.cached_property.
In one line: Descriptor = instance of a class defining at least one of
__get__/__set__/__delete__. Priority is data descriptor (__set__present) > instance dict > non-data descriptor.@property/ methods /cached_property/__slots__are all forms of descriptors.__set_name__(3.6+) for auto-naming. Daily code is enough with@property; only library/framework code writes descriptors directly.
Next chapter #
Next, Chapter 17 metaclasses — when do you really need them? steps further — covering the role of classes that build classes. Plus the division between metaclass / __init_subclass__ / descriptor / decorator.