Python Descriptors Explained (__get__, __set__, __delete__)

Blog / Python · April 28, 2021 · Updated June 10, 2026 · 10 min read
Python Descriptors Explained (__get__, __set__, __delete__)

A descriptor is the protocol that quietly powers some of Python's most-used features — property, bound methods, classmethod, staticmethod, functools.cached_property, and the typed fields you see in Django models and SQLAlchemy. Once you understand descriptors, a lot of the "magic" in Python becomes ordinary, predictable code. They are one of the sharper tools in everyday Python development.

In short, a descriptor is any object that lives on a class and customises what happens when you read, assign, or delete an attribute on its instances. This guide covers the full protocol with correct Python 3 syntax, the all-important data vs non-data distinction, the modern __set_name__ hook, and honest guidance on when a plain property or dataclass is the better choice.

What is a descriptor?

A descriptor is any object that implements at least one of these three methods, together known as the descriptor protocol:

  • __get__(self, obj, objtype=None) — called when the attribute is read
  • __set__(self, obj, value) — called when the attribute is assigned
  • __delete__(self, obj) — called when the attribute is deleted with del

The descriptor instance is assigned as a class attribute on the owner class. When you then touch that attribute on an instance, Python routes the access through these methods instead of the normal __dict__ lookup. In the signatures, self is the descriptor instance, obj is the instance the attribute is accessed on (or None when accessed on the class itself), and objtype is the owner class.

class Descriptor:
    def __set_name__(self, owner, name):
        # Python 3.6+: receives the attribute name the descriptor was bound to
        self.name = name

    def __get__(self, obj, objtype=None):
        print("__get__ called")
        return obj.__dict__.get(self.name)

    def __set__(self, obj, value):
        print("__set__ called")
        obj.__dict__[self.name] = value

    def __delete__(self, obj):
        print("__delete__ called")
        del obj.__dict__[self.name]


class Person:
    name = Descriptor()   # the descriptor lives on the CLASS
>>> p = Person()
>>> p.name = "Ada Lovelace"      # triggers Descriptor.__set__
__set__ called
>>> p.name                       # triggers Descriptor.__get__
__get__ called
'Ada Lovelace'
>>> del p.name                   # triggers Descriptor.__delete__
__delete__ called

Where to store the value (and the bug to avoid)

Notice the example stores the value in obj.__dict__[self.name] — the instance's own namespace — not on the descriptor. This matters. The descriptor object is created once and shared by every instance of the owner class. If you store state on self (the descriptor), every instance reads and writes the same slot:

class Broken:
    def __get__(self, obj, objtype=None):
        return self.value
    def __set__(self, obj, value):
        self.value = value          # BUG: stored on the shared descriptor

class Account:
    balance = Broken()

a, b = Account(), Account()
a.balance = 100
b.balance = 5
print(a.balance)   # 5  -- a and b share a single slot!

The fix is to key each value by the attribute name inside that instance's __dict__, exactly as the skeleton does. Writing into obj.__dict__ directly (rather than setattr(obj, ...)) also avoids re-triggering __set__ and the infinite recursion that would cause. Before Python 3.6 you had to pass the name in by hand (balance = Descriptor("balance")); today __set_name__ captures it for you automatically.

Data vs non-data descriptors

This is the single most important idea to internalise, because it decides whether your descriptor or the instance's own __dict__ wins.

  • A data descriptor defines __set__ and/or __delete__ (usually alongside __get__).
  • A non-data descriptor defines only __get__.

The distinction changes attribute-lookup precedence. When you read obj.attr, Python resolves it in this order:

Priority Source Wins when
1 (highest) Data descriptor on the class always — even if the instance __dict__ holds the same key
2 Instance __dict__ no data descriptor of that name exists
3 (lowest) Non-data descriptor on the class the instance __dict__ has no such key

So a data descriptor can never be shadowed by instance data, while a non-data descriptor is overridden the moment the instance stores its own value under that name. That second behaviour is exactly what makes lazy caching possible.

class NonData:
    def __get__(self, obj, objtype=None):
        return "from descriptor"

class Demo:
    x = NonData()

d = Demo()
print(d.x)                       # 'from descriptor'  (non-data descriptor used)
d.__dict__["x"] = "from instance"
print(d.x)                       # 'from instance'    (instance __dict__ shadows it)

Add a __set__ to NonData and it becomes a data descriptor — d.x would then keep returning 'from descriptor' no matter what you put in d.__dict__.

__set_name__: the modern way to capture the name

Python 3.6 added __set_name__(self, owner, name), called automatically when the owning class is created. It hands the descriptor the attribute name it was assigned to, so you no longer repeat it. Here is a reusable, type-checked field built on it:

class Typed:
    """A reusable data descriptor that enforces a type on assignment."""

    def __init__(self, expected_type):
        self.expected_type = expected_type

    def __set_name__(self, owner, name):
        self.name = name                       # captured automatically

    def __get__(self, obj, objtype=None):
        if obj is None:                        # accessed on the class
            return self
        return obj.__dict__[self.name]

    def __set__(self, obj, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(
                f"{self.name!r} must be {self.expected_type.__name__}, "
                f"got {type(value).__name__}"
            )
        obj.__dict__[self.name] = value        # stored per instance


class Product:
    name = Typed(str)
    price = Typed(int)

    def __init__(self, name, price):
        self.name = name
        self.price = price
>>> p = Product("Keyboard", 49)
>>> p.price
49
>>> p.price = "free"
Traceback (most recent call last):
    ...
TypeError: 'price' must be int, got str

Because Typed defines __set__, it is a data descriptor — the validation runs on every assignment and cannot be bypassed through ordinary attribute access. Just as importantly, the same class now guards both name and price (and any number of other fields) without repeating a getter/setter pair for each one. That reuse is the core reason to reach past property for a descriptor.

property is just a descriptor

Everything above explains property: it is a built-in data descriptor that wires your getter, setter, and deleter into __get__, __set__, and __delete__. You can prove it:

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

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


>>> type(Temperature.__dict__["fahrenheit"])
<class 'property'>
>>> hasattr(property, "__get__"), hasattr(property, "__set__")
(True, True)

property defines all three methods, so it is always a data descriptor. Think of a property as the simplest specialised descriptor and a custom descriptor class as the general case. Use property for one-off attributes; switch to a descriptor when the same logic would otherwise be copied across many fields.

Lazy, cached attributes with a non-data descriptor

A non-data descriptor shines for expensive values you want to compute once and remember. Because it has no __set__, the first read can cache the result in the instance __dict__, and every later read finds it there directly — the descriptor is never called again.

class lazy_property:
    """Compute once on first access, then cache on the instance."""

    def __init__(self, func):
        self.func = func
        self.name = func.__name__

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        value = self.func(obj)            # run the expensive work once
        obj.__dict__[self.name] = value   # cache; shadows this non-data descriptor
        return value


class DataSet:
    @lazy_property
    def summary(self):
        print("computing summary...")
        return sum(range(1_000_000))
>>> ds = DataSet()
>>> ds.summary        # computed on first access
computing summary...
499999500000
>>> ds.summary        # served from instance __dict__, no recompute
499999500000

This is exactly what the standard library's functools.cached_property (Python 3.8+) gives you, so in real code prefer it over rolling your own:

from functools import cached_property

class DataSet:
    @cached_property
    def summary(self):
        return sum(range(1_000_000))

One caveat: cached_property needs the instance to have a writable __dict__, so it does not work with __slots__ unless you also add __dict__ to the slots. The cached value lives until you del the attribute or the instance is garbage-collected.

How methods, classmethod and staticmethod use descriptors

Plain functions are themselves non-data descriptors — that is how method binding works. When you write obj.method, Python calls function.__get__(obj, type(obj)), which returns a bound method with self already supplied:

>>> class Greeter:
...     def hello(self):
...         return "hi"
...
>>> Greeter.hello                       # a plain function
<function Greeter.hello at 0x7f9c...>
>>> g = Greeter()
>>> g.hello                             # bound method, produced by __get__
<bound method Greeter.hello of <__main__.Greeter object ...>>
>>> Greeter.hello.__get__(g, Greeter)()  # the binding, done by hand
'hi'

classmethod and staticmethod are descriptors too: each defines __get__ and returns, respectively, a method bound to the class or the underlying function untouched. The same protocol that powers your custom fields powers the method-call syntax you use every day.

This is also how ORMs work. In Django, model fields and the objects manager are descriptors — Model.objects returns a manager via __get__, and a ForeignKey returns related rows lazily. SQLAlchemy's mapped columns behave the same way: User.name == "x" builds a SQL expression rather than comparing strings, because name is a descriptor, not a plain value.

When NOT to reach for a descriptor

Descriptors are powerful but they add indirection, and most attribute needs are simpler. Prefer the lightest tool that works:

  • A single computed or validated attribute → @property.
  • One expensive value cached per instance → functools.cached_property.
  • Plain data holders → a @dataclass (add __slots__ to save memory).
  • The same getter/setter logic repeated across many attributes → that is the real case for a custom reusable descriptor.
Tool Kind Reuse across fields Recomputes Best for
Plain attribute n/a n/a simple mutable data, no rules
@property data descriptor one field at a time every access validation or a derived value on a single field
functools.cached_property non-data descriptor one field at a time once, then cached expensive, read-mostly values
Custom descriptor data or non-data yes — one class, many fields you decide the same behaviour shared by many attributes
@dataclass field n/a n/a plain records with little behaviour

If you only have one or two special attributes, a descriptor is usually over-engineering. Their payoff appears when the same behaviour repeats.

Keep learning

Descriptors sit at the centre of Python's object model, so they connect to several neighbouring topics:

We have leaned on these patterns to build maintainable, well-typed Python systems across 12+ years and 50+ delivered projects. If you are weighing descriptors against simpler options in your own codebase, our team is glad to help you pick the right tool.

Frequently Asked Questions

What is a Python descriptor?

A descriptor is any object that defines one or more of __get__, __set__, and __delete__ and is assigned as a class attribute. When you access that attribute on an instance, Python routes the read, write, or delete through those methods instead of touching the instance __dict__ directly. Descriptors are the mechanism behind property, bound methods, classmethod, staticmethod, and ORM fields.

What is the difference between a data and a non-data descriptor?

A data descriptor defines __set__ and/or __delete__; a non-data descriptor defines only __get__. The difference is precedence: a data descriptor always wins over a value stored in the instance __dict__, while a non-data descriptor is shadowed as soon as the instance stores its own value under the same name. That shadowing is what makes lazy caching — compute once, then read straight from __dict__ — possible.

What does __set_name__ do?

__set_name__(self, owner, name) was added in Python 3.6 and runs automatically when the owning class is defined. It tells the descriptor the attribute name it was bound to, so you can store each instance's value in obj.__dict__[name] without passing the name manually. Before 3.6 you had to write field = Descriptor("field") and repeat the name in two places.

Should I use a descriptor or a property?

Use property when you need custom behaviour on a single attribute — it is the simplest specialised descriptor. Write a custom descriptor class when you would otherwise copy the same getter/setter logic across many attributes, such as type or range validation on a dozen fields. One descriptor class can serve every field, where properties force you to repeat the pair each time.

How does property use descriptors?

property is itself a built-in data descriptor: it implements __get__, __set__, and __delete__ and calls the getter, setter, and deleter functions you supply. Reading obj.attr invokes the property's __get__, assignment invokes __set__, and del invokes __delete__. You can confirm it with type(MyClass.__dict__["attr"]), which prints <class 'property'>.

How do methods relate to descriptors?

Functions are non-data descriptors. Accessing obj.method calls the function's __get__(obj, type(obj)), which returns a bound method with self already filled in — that is the entire mechanism behind method binding. classmethod and staticmethod are descriptors too: they define __get__ to bind to the class or to leave the function unbound, respectively.

Share this article