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 labelData 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__ winsclass 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.0Functions 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 strMental 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)