
Descriptors in Python: The Mechanism Behind Properties and Methods
You've used descriptors many times, probably without knowing it. Every @property, every @classmethod and @staticmethod, every functools.cached_property, and every ordinary method call goes through the same small protocol. When you write obj.method(), Python doesn't just find a function and call it: it asks the function to describe what obj.method should be, and the function answers with a bound method.
Understanding that protocol does two things for you. It demystifies a lot of Python behaviour (why methods get self automatically, why a property can't be overwritten by assigning to an instance, why cached_property can), and it gives you a tool for writing reusable attribute logic, like a validated field you can drop into any class.
In this post I'll cover the descriptor protocol methods, the attribute lookup order that makes them work, data versus non-data descriptors, how functions become methods, and how to rebuild property, classmethod, and a lazy attribute yourself. Then we'll build a practical validated-field descriptor.
What Is a Descriptor?
A descriptor is any object that defines at least one of these methods, and is stored as a class attribute of another class:
| Method | Called when |
|---|---|
__get__(self, instance, owner=None) | The attribute is read |
__set__(self, instance, value) | The attribute is assigned on an instance |
__delete__(self, instance) | The attribute is deleted with del |
__set_name__(self, owner, name) | The owning class is created (Python 3.6+) |
The important phrase is "class attribute". A descriptor stored in an instance's __dict__ does nothing special. It's the class that holds the descriptor, and the descriptor intercepts access through every instance of that class.
Watching the Protocol in Action
The quickest way to see what's going on is a descriptor that prints every call:
class Verbose:
def __set_name__(self, owner, name):
self.name = name
print(f"__set_name__: {owner.__name__}.{name}")
def __get__(self, instance, owner=None):
print(f"__get__ instance={instance!r} owner={owner.__name__}")
if instance is None:
return self
return instance.__dict__.get(self.name, "unset")
def __set__(self, instance, value):
print(f"__set__ {self.name} = {value!r}")
instance.__dict__[self.name] = value
class Demo:
attr = Verbose()
def __repr__(self):
return "Demo()"
print("--- class created")
d = Demo()
d.attr = 10
print(d.attr)
print(Demo.attr)
__set_name__: Demo.attr
--- class created
__set__ attr = 10
__get__ instance=Demo() owner=Demo
10
__get__ instance=None owner=Demo
<__main__.Verbose object at 0x101139a90>
A few things to notice:
__set_name__runs while theclass Demo:statement is being executed, before any instance exists. It tells the descriptor which attribute name it was assigned to, so you don't have to pass the name in manually.d.attr = 10didn't put anything in the instance dictionary directly. It called__set__, which chose to store the value there.d.attrcalled__get__with the instance.Demo.attralso called__get__, but withinstance=None. The convention is to return the descriptor itself in that case, which is whyDemo.attrgives you the descriptor object (and whySomeClass.some_propertygives you apropertyobject).
The Attribute Lookup Order
To understand descriptors, you need the rules object.__getattribute__ follows for obj.name. Here's a simplified version written in Python:
def lookup(obj, name):
"""Simplified version of object.__getattribute__."""
cls = type(obj)
cls_attr = None
for klass in cls.__mro__:
if name in klass.__dict__:
cls_attr = klass.__dict__[name]
break
# 1. Data descriptors on the class win
if cls_attr is not None and hasattr(type(cls_attr), "__set__"):
return type(cls_attr).__get__(cls_attr, obj, cls)
# 2. Then the instance dictionary
if name in getattr(obj, "__dict__", {}):
return obj.__dict__[name]
# 3. Then non-data descriptors (functions, cached_property, ...)
if cls_attr is not None and hasattr(type(cls_attr), "__get__"):
return type(cls_attr).__get__(cls_attr, obj, cls)
# 4. Then plain class attributes
if cls_attr is not None:
return cls_attr
raise AttributeError(name)
It works on real classes:
class Account:
currency = "EUR"
def __init__(self, owner: str) -> None:
self.owner = owner
@property
def label(self) -> str:
return f"{self.owner} ({self.currency})"
def close(self) -> str:
return "closed"
acct = Account("Ada")
print(lookup(acct, "owner"), lookup(acct, "label"), lookup(acct, "currency"))
print(lookup(acct, "close")())
Ada Ada (EUR) EUR
closed
The real implementation is in C and handles a few more details (the data-descriptor check also counts __delete__, and __getattr__ is tried last if everything fails), but the order is accurate. Two details matter:
- Python looks up
__get__and__set__on the descriptor's type, not the descriptor instance. That's why you can't make something a descriptor by attaching a__get__attribute to one object. - The instance dictionary sits between the two kinds of descriptors. That's the whole difference between them.
Data vs Non-Data Descriptors
- A data descriptor defines
__set__or__delete__. It takes priority over the instance dictionary. - A non-data descriptor defines only
__get__. The instance dictionary takes priority over it.
You can see the difference by planting values in the instance dictionary directly:
class DataDesc:
def __get__(self, instance, owner=None):
return "from data descriptor"
def __set__(self, instance, value):
raise AttributeError("read-only")
class NonDataDesc:
def __get__(self, instance, owner=None):
return "from non-data descriptor"
class C:
a = DataDesc()
b = NonDataDesc()
c = C()
c.__dict__["a"] = "from instance dict"
c.__dict__["b"] = "from instance dict"
print(c.a)
print(c.b)
from data descriptor
from instance dict
This one rule explains a lot of everyday behaviour:
propertydefines__set__(even a read-only property, where it raisesAttributeError), so it's a data descriptor. That's whyobj.some_property = 5can't shadow it with an instance value.- Functions define only
__get__, so they're non-data descriptors. You can shadow a method on one instance withobj.method = something. functools.cached_propertydefines only__get__. On first access it computes the value and stores it in the instance dictionary under the same name. Next time, rule 2 finds the instance value before rule 3 ever consults the descriptor, so the function isn't called again.
How Functions Become Methods
Here's the bit that surprises people most. Plain functions are descriptors:
class Greeter:
def __init__(self, name: str) -> None:
self.name = name
def greet(self) -> str:
return f"Hello, {self.name}"
g = Greeter("Ada")
func = Greeter.__dict__["greet"]
print(func)
print(hasattr(func, "__get__"), hasattr(func, "__set__"))
<function Greeter.greet at 0x102f5c180>
True False
Inside the class dictionary, greet is an ordinary function. When you access g.greet, lookup reaches rule 3 and calls the function's __get__, which returns a bound method: a small object holding both the function and the instance.
bound = func.__get__(g, Greeter)
print(bound)
print(bound(), bound.__self__ is g, bound.__func__ is func)
<bound method Greeter.greet of <__main__.Greeter object at 0x102f89a90>>
Hello, Ada True True
Calling the bound method calls func(g). That's where self comes from. There's no special method-call syntax in the language; it's the descriptor protocol plus a function's __get__.
Accessing the function through the class (Greeter.greet) passes instance=None, and functions return themselves in that case, so Greeter.greet is func is True. A side effect: each g.greet access creates a fresh bound method, so g.greet == g.greet is True but g.greet is g.greet is False.
Rebuilding the Built-Ins
A good way to make the protocol stick is to reimplement the built-in decorators. These are simplified but behave the same for normal use.
A Minimal property
class MyProperty:
def __init__(self, fget=None, fset=None):
self.fget = fget
self.fset = fset
def __get__(self, instance, owner=None):
if instance is None:
return self
return self.fget(instance)
def __set__(self, instance, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(instance, value)
def setter(self, fset):
return type(self)(self.fget, fset)
class Temperature:
def __init__(self, celsius: float) -> None:
self._celsius = celsius
@MyProperty
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(100)
print(t.fahrenheit) # 212.0
t.fahrenheit = 32
print(t._celsius) # 0.0
@MyProperty replaces the fahrenheit function with a descriptor holding it as fget. @fahrenheit.setter returns a new descriptor with both functions, which replaces the first one under the same name. That's exactly why, with the real property, the setter function must use the same name as the getter: give it a different name and the setter-enabled descriptor ends up under that other name, leaving the original attribute read-only. The @property post covers how to use properties; this is how they work.
staticmethod and classmethod
class MyStaticMethod:
def __init__(self, func):
self.func = func
def __get__(self, instance, owner=None):
return self.func # no binding at all
class MyClassMethod:
def __init__(self, func):
self.func = func
def __get__(self, instance, owner=None):
if owner is None:
owner = type(instance)
return self.func.__get__(owner, type(owner)) # bind to the class
class Tools:
@MyStaticMethod
def add(a, b):
return a + b
@MyClassMethod
def make(cls):
return cls.__name__
print(Tools.add(2, 3), Tools().add(2, 3), Tools.make(), Tools().make())
# 5 5 Tools Tools
A static method's __get__ returns the raw function, so nothing gets bound. A class method's __get__ binds the function to the class instead of the instance. Same protocol, three different answers to "what should obj.name be?" If you want the usage side of these, see Class Methods vs Static Methods vs Instance Methods.
A Lazy Attribute (Like cached_property)
class lazy:
def __init__(self, func):
self.func = func
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner=None):
if instance is None:
return self
value = self.func(instance)
instance.__dict__[self.name] = value # shadows the descriptor from now on
return value
class Report:
def __init__(self, rows):
self.rows = rows
@lazy
def summary(self):
print("computing summary...")
return sum(self.rows)
r = Report([1, 2, 3])
print(r.summary)
print(r.summary)
print(vars(r))
computing summary...
6
6
{'rows': [1, 2, 3], 'summary': 6}
Because lazy is a non-data descriptor, storing the result in the instance dictionary is all the caching you need. The second access never reaches the descriptor. This only works on classes with a __dict__, which is why cached_property doesn't work with __slots__. In real code, use functools.cached_property; it does the same thing with better error messages.
A Practical Descriptor: Validated Fields
Properties are great for one attribute. When the same validation applies to many attributes across many classes, you end up copy-pasting near-identical getters and setters. A descriptor packages that logic once.
# validators.py
class Positive:
"""A data descriptor that only accepts numbers greater than zero."""
def __set_name__(self, owner: type, name: str) -> None:
self.public_name = name
self.private_name = f"_{name}"
def __get__(self, instance, owner=None):
if instance is None:
return self
return getattr(instance, self.private_name)
def __set__(self, instance, value) -> None:
if not isinstance(value, (int, float)):
raise TypeError(f"{self.public_name} must be a number, got {type(value).__name__}")
if value <= 0:
raise ValueError(f"{self.public_name} must be > 0, got {value}")
setattr(instance, self.private_name, value)
class Product:
price = Positive()
quantity = Positive()
def __init__(self, name: str, price: float, quantity: int) -> None:
self.name = name
self.price = price
self.quantity = quantity
@property
def total(self) -> float:
return self.price * self.quantity
Using it:
p = Product("Mug", 12.5, 4)
print(p.total, vars(p))
for bad in (-1, "ten"):
try:
p.quantity = bad
except (TypeError, ValueError) as e:
print(type(e).__name__, e)
Product("Lamp", 0, 1)
50.0 {'name': 'Mug', '_price': 12.5, '_quantity': 4}
ValueError quantity must be > 0, got -1
TypeError quantity must be a number, got str
ValueError: price must be > 0, got 0
Some design points:
- Where the value lives. One descriptor instance is shared by every
Product, so it can't store the value on itself; that would make every product share one price. It stores the value on the instance under a private name (_price). Storing it under the same name ininstance.__dict__also works for a data descriptor, since data descriptors take priority anyway; the private name just makesvars()output clearer. __set_name__removes repetition. Without it you'd writeprice = Positive("price").- Validation runs in
__init__too, becauseself.price = pricegoes through__set__. Invalid objects can't be constructed. - Error messages use the attribute name, which makes debugging much easier than a generic "invalid value".
This is the same pattern ORMs use. A SQLAlchemy mapped column or a Django model field on the class is a descriptor-backed attribute that converts and tracks values per instance. If you only need validation for data coming in from outside, a library like Pydantic is usually the better tool; descriptors shine when you want plain classes with a few guarded attributes.
When to Write Your Own Descriptor
Write one when you have attribute behaviour that repeats across attributes or classes: validation, type coercion, unit conversion, change tracking, lazy loading, or attribute access logging. For a one-off computed or guarded attribute, @property is simpler and more readable.
A few practical tips:
- Always handle
instance is Nonein__get__and returnself. Tools likehelp(), documentation generators, and IDEs access attributes on the class. - Use
__set_name__instead of passing names by hand. - Decide deliberately whether you need a data descriptor (control writes, can't be shadowed) or a non-data one (allow per-instance override, e.g. caching).
- Don't store per-instance values on the descriptor itself. If you can't use the instance dictionary (slotted classes), a
weakref.WeakKeyDictionarykeyed by instance is the fallback, at the cost of requiring hashable, weak-referenceable instances.
The official Descriptor HowTo Guide goes further, including pure-Python equivalents of the built-ins, and it's well worth a read once the basics click.
Conclusion
Descriptors are the hook Python uses to customise attribute access: an object on a class with __get__, __set__, or __delete__ gets to decide what obj.name means. Data descriptors (with __set__) beat the instance dictionary; non-data descriptors (only __get__) lose to it. That single ordering rule explains properties, methods, classmethod, staticmethod, and cached_property.
Most of the time you'll use descriptors indirectly through those built-ins. When you find yourself writing the same property logic for the third time, a small descriptor class with __set_name__ is usually the cleanest way to write it once.


