Type something to search...
Descriptors in Python: The Mechanism Behind Properties and Methods

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:

MethodCalled 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 the class 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 = 10 didn't put anything in the instance dictionary directly. It called __set__, which chose to store the value there.
  • d.attr called __get__ with the instance.
  • Demo.attr also called __get__, but with instance=None. The convention is to return the descriptor itself in that case, which is why Demo.attr gives you the descriptor object (and why SomeClass.some_property gives you a property object).

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:

  1. 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.
  2. 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:

  • property defines __set__ (even a read-only property, where it raises AttributeError), so it's a data descriptor. That's why obj.some_property = 5 can'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 with obj.method = something.
  • functools.cached_property defines 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 in instance.__dict__ also works for a data descriptor, since data descriptors take priority anyway; the private name just makes vars() output clearer.
  • __set_name__ removes repetition. Without it you'd write price = Positive("price").
  • Validation runs in __init__ too, because self.price = price goes 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 None in __get__ and return self. Tools like help(), 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.WeakKeyDictionary keyed 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.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading