Modern Python Advanced #2: Descriptors and __set_name__
In #1 Magic methods in depth we saw __getattr__/__setattr__ as hooks intercepting attribute access. One step further — the tool that turns the attribute itself into an object is the descriptor. @property, @classmethod, @staticmethod, ORM Column, dataclass fields — all built on top of descriptors.
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. Any code can be inserted here.
__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("음수 불가")
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:]} 음수 불가")
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 필요, got {type(value).__name__}")
if not (self.lo <= value <= self.hi):
raise ValueError(f"{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 필요")
if not (self.min_len <= len(value) <= self.max_len):
raise ValueError("길이 범위 벗어남")
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.
Descriptor vs @property — when which?
#
| Case | 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("계산 중...")
return len(self.text.split())
d = Document("a b c d")
print(d.word_count) # 계산 중... 4
print(d.word_count) # 4 (cached — no recomputation)Computed once on first access and stored in the instance dict, subsequent accesses behave 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("삭제 금지")
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 Intermediate #1 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.
Wrap-up #
What this post covered:
- Descriptor = instance of a class defining at least one of
__get__/__set__/__delete__ - Placed as a class attribute, attribute access becomes a method call
- Data descriptor (
__set__) > instance dict > non-data descriptor @property, methods,cached_propertyare all forms of descriptors- Validation descriptor: store as backing fields in instance dict
__set_name__(3.6+) gets the name automatically — solves DRY- Descriptors live in libraries/frameworks;
@propertyis enough for daily code __slots__also acts via descriptors
In the next post (#3 Metaclasses) we step further — classes that build classes, the role of metaclasses. Plus the division between metaclass / __init_subclass__ / descriptor / decorator.