Descriptors

A descriptor is an object that customizes what happens when its owning class's attribute is accessed, set, or deleted — the mechanism underneath property, methods, and @staticmethod.

The descriptor protocol is one of Python's most powerful and least visible features: it's how a plain-looking attribute access like obj.value can actually run code — validating input, computing a value lazily, or binding a function into a method. Any class that defines __get__, __set__, or __delete__ and is stored as a class attribute becomes a descriptor, and Python's attribute-lookup machinery (see the related Attribute Lookup topic) automatically routes through those methods instead of returning the object itself.

What Is the Descriptor Protocol

A descriptor is any object whose class defines one or more of __get__(self, instance, owner), __set__(self, instance, value), or __delete__(self, instance). When such an object is assigned as a *class* attribute (not an instance attribute) of some other class, accessing that attribute on instances of the owning class no longer just returns the descriptor object itself — it calls the descriptor's methods instead. This is the mechanism behind property, bound methods, @staticmethod, and @classmethod — all of them are descriptors under the hood.

__get__, __set__, and __delete__

__get__(self, instance, owner) is called when the attribute is read; instance is the object it was accessed through (None if accessed on the class itself), and owner is the class. __set__(self, instance, value) is called on assignment (obj.attr = value). __delete__(self, instance) is called on del obj.attr.

class LoggedAttribute:
    def __init__(self, name: str):
        self.name = name

    def __get__(self, instance, owner):
        if instance is None:
            return self   # accessed on the class itself
        print(f"getting {self.name}")
        return instance.__dict__.get(self.name)

    def __set__(self, instance, value):
        print(f"setting {self.name} = {value}")
        instance.__dict__[self.name] = value

    def __delete__(self, instance):
        print(f"deleting {self.name}")
        del instance.__dict__[self.name]

class Widget:
    label = LoggedAttribute("label")

w = Widget()
w.label = "Button"     # setting label = Button
print(w.label)          # getting label -> Button
del w.label              # deleting label

Data vs Non-Data Descriptors

This distinction controls precedence during attribute lookup (see the related Attribute Lookup topic for the full algorithm). A data descriptor defines __set__ and/or __delete__ (with or without __get__) — it takes priority over an instance's own __dict__ entry of the same name. A non-data descriptor defines only __get__ — an instance attribute of the same name in instance.__dict__ takes priority over it instead.

Kind                | Defines               | Wins against instance __dict__?
---------------------|------------------------|--------------------------------
data descriptor      | __get__ + __set__/__delete__, or just __set__/__delete__ | yes
non-data descriptor  | __get__ only           | no — instance __dict__ wins
class NonDataDescriptor:
    def __get__(self, instance, owner):
        return "from descriptor"

class C:
    attr = NonDataDescriptor()

c = C()
print(c.attr)          # 'from descriptor' -- no instance override yet
c.__dict__["attr"] = "from instance dict"
print(c.attr)          # 'from instance dict' -- instance dict wins (non-data descriptor)

property Is a Data Descriptor

The built-in property decorator is implemented as a data descriptor: it defines __get__ (running your getter), __set__ (running your setter, or raising AttributeError if you didn't define one), and __delete__. This is why property always takes precedence over anything you might try to store in instance.__dict__ under the same name — and why assigning directly into instance.__dict__[name] to 'bypass' a read-only property doesn't work as an end-run around it (the property's __get__ still intercepts normal attribute access).

fahrenheit itself is never stored anywhere — every access runs the property's __get__/__set__, which is exactly the descriptor protocol at work.

class Temperature:
    def __init__(self, celsius: float):
        self._celsius = celsius

    @property
    def fahrenheit(self) -> float:
        return self._celsius * 9 / 5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value: float) -> None:
        self._celsius = (value - 32) * 5 / 9

t = Temperature(0)
print(t.fahrenheit)     # 32.0
t.fahrenheit = 212
print(t._celsius)        # 100.0

Functions Are Non-Data Descriptors: How Methods Work

Plain functions define __get__ (making them non-data descriptors), which is precisely the mechanism that turns a function stored on a class into a bound method when accessed through an instance: instance.method calls the function object's __get__(instance, owner), which returns a new bound-method object with instance pre-bound as the first argument (self).

@staticmethod and @classmethod are also descriptor-based wrapper objects: staticmethod.__get__ returns the plain underlying function (no self binding), and classmethod.__get__ returns a bound method with the *class* (not an instance) pre-bound as the first argument.

class Greeter:
    def hello(self):
        return f"hello from {self}"

g = Greeter()
print(Greeter.__dict__["hello"])    # the plain function object
print(g.hello)                       # <bound method Greeter.hello of ...>
print(g.hello.__self__ is g)          # True -- self was bound via __get__

Writing a Custom Descriptor

A typical use case: a reusable validating attribute you can attach to multiple classes without repeating validation logic in every __init__/property.

One PositiveNumber implementation is reused for both price and quantity, each storing its own value under a distinct private attribute name.

class PositiveNumber:
    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):
        if value <= 0:
            raise ValueError(f"{value!r} must be positive")
        setattr(instance, self.private_name, value)

class Product:
    price = PositiveNumber()
    quantity = PositiveNumber()

    def __init__(self, price: float, quantity: int):
        self.price = price
        self.quantity = quantity

p = Product(9.99, 3)
try:
    p.quantity = -1
except ValueError as exc:
    print(exc)   # -1 must be positive

__set_name__

__set_name__(self, owner, name) is called automatically once, at class-creation time, for every descriptor assigned as a class attribute — owner is the class it was defined on and name is the attribute name it was assigned to. This lets a descriptor learn its own name without it being passed explicitly to __init__, which is what made the reusable PositiveNumber example above possible: each instance of the descriptor automatically knows to store its value under _price or _quantity respectively.

Practical Use Cases

Beyond property itself, descriptors are the mechanism behind: validated/typed attributes (as above), lazily-computed cached attributes (compute once on first access, then store the result), ORMs mapping class attributes to database columns (each field descriptor knows how to read/write the underlying storage), and functools.cached_property, which is a standard-library non-data descriptor that computes a value once and then stores it directly in the instance's __dict__ — subsequent access hits the instance dict directly (bypassing the descriptor, because it's non-data) for speed.

from functools import cached_property

class Report:
    def __init__(self, rows: list[int]):
        self.rows = rows

    @cached_property
    def total(self) -> int:
        print("computing total...")
        return sum(self.rows)

r = Report([1, 2, 3])
print(r.total)   # computing total... -> 6
print(r.total)   # 6 (no recomputation -- cached in r.__dict__)

Edge Cases

Descriptors only take effect when found via the *class*, not when stored directly in an instance's __dict__ under a name unrelated to any class attribute — the descriptor protocol is a class-attribute-lookup mechanism, not something that activates for arbitrary instance attributes.

A common bug: storing a descriptor's per-instance value directly under the *same* name as the descriptor itself (e.g. instance.__dict__['label'] = value inside LoggedAttribute.__set__ when the descriptor is also named label) creates infinite recursion for a data descriptor, because the very next read of instance.label re-triggers the descriptor. Store under a different private name instead (as PositiveNumber does with self.private_name).

Descriptor instances defined on a class are shared across all instances of that class (there's exactly one PositiveNumber() object for price, shared by every Product) — this is exactly why a descriptor should never store per-instance state on self inside itself; it must store it on the passed-in instance instead.

Best Practices

Use __set_name__ to derive a private storage attribute name automatically rather than hardcoding or requiring it as a constructor argument.

Never store per-instance state as an attribute of the descriptor object itself — store it on the instance parameter passed into __get__/__set__.

Reach for property for one-off computed attributes on a single class; write a custom descriptor when the same validation/computation logic needs to be reused across multiple attributes or classes.

Prefer the standard-library functools.cached_property over hand-rolling a caching descriptor for the common 'compute once, cache on the instance' pattern.

Complete Example

class TypedAttribute:
    def __init__(self, expected_type: type):
        self.expected_type = expected_type

    def __set_name__(self, owner, name):
        self.private_name = f"_{name}"

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.__dict__[self.private_name]

    def __set__(self, instance, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(f"expected {self.expected_type.__name__}, got {type(value).__name__}")
        instance.__dict__[self.private_name] = value

class Person:
    name = TypedAttribute(str)
    age = TypedAttribute(int)

    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

alice = Person("Alice", 30)
print(alice.name, alice.age)
try:
    alice.age = "thirty"
except TypeError as exc:
    print(exc)   # expected int, got str

Mental Model

Think of a descriptor as a small, reusable middleman a class attribute can appoint: instead of the class attribute *being* the value, it's an object whose job is to compute, validate, or fetch the real value on every access. obj.attr looks like reading a box, but when attr is a descriptor, it's really asking that middleman 'what should I return right now for this specific obj?'

Quick Reference

__get__(self, instance, owner)     # called on attribute read
__set__(self, instance, value)     # called on attribute assignment
__delete__(self, instance)         # called on del
__set_name__(self, owner, name)    # called once at class-creation time

data descriptor:     defines __set__ and/or __delete__ -> beats instance __dict__
non-data descriptor: defines only __get__               -> loses to instance __dict__

property             -> data descriptor (getter/setter/deleter)
plain function        -> non-data descriptor (produces bound methods)
staticmethod          -> descriptor returning the plain function, no binding
classmethod            -> descriptor returning a method bound to the class
functools.cached_property -> non-data descriptor, caches into instance __dict__

Key Takeaways

A descriptor is any object implementing __get__/__set__/__delete__; assigning one as a class attribute makes attribute access on instances route through those methods.

Data descriptors (define __set__/__delete__) take precedence over instance __dict__; non-data descriptors (only __get__) lose to it.

property, bound methods, staticmethod, and classmethod are all built on the descriptor protocol.

__set_name__ lets a descriptor learn its own attribute name automatically at class-creation time.

Descriptor instances are shared across all instances of the owning class — never store per-instance state on the descriptor itself.

functools.cached_property is the standard-library tool for the common compute-once-and-cache pattern.

class Attr: def __set__(self, instance, value): instance.attr = value # re-triggers __set__ -> infinite recursion! class C: attr = Attr() class Attr: def __set_name__(self, owner, name): self.private_name = f"_{name}" def __set__(self, instance, value): instance.__dict__[self.private_name] = value class C: attr = Attr()

Setting instance.attr (the same name the descriptor is bound to) inside its own __set__ re-invokes the descriptor, causing infinite recursion; store under a distinct private name instead.

class Counter: def __init__(self): self.count = 0 # stored on the descriptor, shared by every instance! def __get__(self, instance, owner): self.count += 1 return self.count class C: hits = Counter() class Counter: def __get__(self, instance, owner): if instance is None: return self current = instance.__dict__.get("_hits", 0) + 1 instance.__dict__["_hits"] = current return current class C: hits = Counter()

A descriptor instance is shared by every instance of the owning class; storing state on self inside the descriptor leaks state across all instances instead of tracking each one separately.

What makes an object a descriptor?

Its class defines __get__, __set__, and/or __delete__, and the object is assigned as a class attribute (not an instance attribute) of some other class.

What is the difference between a data descriptor and a non-data descriptor, and why does it matter?

A data descriptor defines __set__ and/or __delete__ (with or without __get__) and always takes precedence over an instance's own __dict__ entry; a non-data descriptor defines only __get__ and loses to an instance __dict__ entry of the same name — this ordering matters for the attribute lookup algorithm.

Which built-in decorator is implemented as a data descriptor, and why does that matter for read-only properties?

property is a data descriptor (defining __get__/__set__/__delete__); because it's a data descriptor it always wins over anything in instance.__dict__, so you can't bypass a read-only property by writing directly into the instance's __dict__ under the same name.

How does the descriptor protocol explain why calling instance.method() works with self automatically bound?

Plain functions are non-data descriptors; accessing a function through an instance calls the function's __get__(instance, owner), which returns a bound method with instance pre-bound as the first argument (self).

What does __set_name__ do and when is it called?

It's called automatically once for each descriptor at class-creation time, receiving the owning class and the attribute name it was assigned to, letting the descriptor derive a private storage name without it being passed explicitly.

Why must a custom descriptor store per-instance state on the passed-in instance rather than on self?

A descriptor object is a single shared instance attached to the class, not duplicated per object instance — storing state on self would leak and mix state across every instance of the owning class.

What does functools.cached_property do, and why is it a non-data descriptor?

It computes a value once on first access and stores the result directly into the instance's __dict__ under the same attribute name; being a non-data descriptor (only __get__), subsequent lookups find the cached instance-dict value first and skip re-invoking the descriptor entirely.

Write a descriptor NonNegative that raises ValueError on __set__ if the assigned value is negative, using __set_name__ to pick its private storage name, and attach it as a class attribute 'balance' on an Account class.

class NonNegative: def __set_name__(self, owner, name): self.private_name = f"_{name}" def __get__(self, instance, owner): if instance is None: return self return instance.__dict__[self.private_name] def __set__(self, instance, value): if value < 0: raise ValueError("balance cannot be negative") instance.__dict__[self.private_name] = value class Account: balance = NonNegative() def __init__(self, balance: float): self.balance = balance acc = Account(100) try: acc.balance = -50 except ValueError as exc: print(exc)

Prints 'balance cannot be negative'; assigning acc.balance = -50 invokes NonNegative.__set__, which validates and rejects the negative value before it's ever stored.

What does this print, and why?

Prints 'instance value'. NonData defines only __get__, making it a non-data descriptor, which loses to an entry of the same name already present in the instance's __dict__.

Write a caching descriptor Lazy that computes a value using a provided function only on first access per instance, storing the result so subsequent accesses skip recomputation — without using functools.cached_property.

class Lazy: def __init__(self, func): self.func = func self.attr_name = func.__name__ def __get__(self, instance, owner): if instance is None: return self if self.attr_name not in instance.__dict__: instance.__dict__[self.attr_name] = self.func(instance) return instance.__dict__[self.attr_name] class Data: def __init__(self, values): self.values = values @Lazy def total(self): print("computing...") return sum(self.values) d = Data([1, 2, 3]) print(d.total) # computing... -> 6 print(d.total) # 6, no recomputation

Because Lazy defines only __get__ (a non-data descriptor), once the computed value is stored into instance.__dict__['total'], later attribute lookups find the instance dict entry directly and never call __get__ again — matching cached_property's behavior.

class NonData:
    def __get__(self, instance, owner):
        return "descriptor value"

class C:
    x = NonData()

c = C()
c.__dict__["x"] = "instance value"
print(c.x)