Modern Python Advanced #2: Descriptors and __set_name__

3 min read

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.

The simplest descriptor
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 None

A 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 instance
  • instance — the object accessing the attribute. None when accessed via the class itself
  • owner — the class that owns the attribute

Data vs non-data descriptors #

This is the most confusing part of descriptors.

KindDefinitionPriority
Data descriptorhas __set__ or __delete__takes precedence over instance dict
Non-data descriptoronly __get__instance dict takes precedence

The actual difference:

Data descriptor
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 wins
Non-data descriptor
class 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 wins

Why this priority matters:

  • Methods are non-data descriptors. Putting an attribute of the same name on the instance wins (allows method overrides).
  • @property is a data descriptor. Even if a user creates an instance attribute with the same name, the property always runs.

@property is actually a descriptor #

The role of property
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 called

The @property decorator builds an instance of the property descriptor class. Circle.area isn’t a function — it’s a property object. Building it ourselves:

Building property
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.5

The standard library property adds setter/deleter on top.

User-defined — validation descriptor #

A common pattern is a descriptor that validates an attribute.

🚫 First try — conflict
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 = value

Problem: 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.

✅ Store on the instance
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.

Old style — don't use in new code
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.

Validator base
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 = ""         # ValueError

The same pattern lets you build regex validation, email format validation, and so on as descriptors.

Descriptor vs @property — when which? #

CaseFit
One or two validations in a single class@property (simple)
Same validation pattern across many classes / fieldsdescriptor (reuse)
Simple computation (read-only)@property or @cached_property
ORM columns, form fields — bundling metadatadescriptor

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.

cached_property
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:

  1. Data descriptor (on class, has __set__ or __delete__) → call
  2. Instance dict → return as-is
  3. Non-data descriptor / class attribute → call if method, else return
  4. __getattr__ → fallback
  5. AttributeError → all failed

This priority is the source of the data vs non-data difference shown above.

__delete__ — deletion hook #

Intercept del
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   # AttributeError

Not used often, but useful for cleanup/rollback timing.

Slots and descriptors #

__slots__ from Intermediate #1 actually turns each slot into a descriptor.

slots are descriptors
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/toolDescriptor role
Built-inproperty, classmethod, staticmethod
functoolscached_property
dataclassesfield() (some compare/factory metadata)
ORM (SQLAlchemy, Django ORM)Column, ForeignKey, model fields
Forms/validation (WTForms, Django forms)field classes
attrs, Pydanticfield 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_property are 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; @property is 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.

X