skip to content

What makes a Python class a descriptor, and what do __get__, __set__ and __delete__ do?

level: juniorimportance: should knowfreq 42%

answer

  1. A protocol, not special syntax
  2. Three methods, one class attribute
  3. Looked up on the type, not the instance
  4. obj is None means class access
  5. __get__ / __set__ / __delete__

basics

~20 s

A descriptor is any class that defines get, set or delete and whose instance is stored as a class attribute. Those three methods intercept reading, assigning and deleting that attribute on instances of the owning class.

solid answer

~40 s

A **descriptor** is an object whose type implements at least one of `__get__(self, obj, objtype=None)`, `__set__(self, obj, value)` or `__delete__(self, obj)`, and which is placed in a class body so it becomes a class attribute. When you read `instance.attr`, CPython looks the name up on the *type*, sees a descriptor, and calls `__get__` instead of handing you the object itself; `instance.attr = x` calls `__set__`, and `del instance.attr` calls `__delete__`. The `obj` argument is the instance, or `None` when the attribute is reached through the class, which is why descriptors usually return `self` in that case. Only descriptors found on a class (or its MRO) are invoked — an object with `__get__` sitting in an instance's `__dict__` is returned as-is. This one protocol is the machinery behind bound methods, `property`, `classmethod` and `staticmethod`.

code

python · 27 lines
python
class Utf8Text:
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__["_label"]

    def __set__(self, obj, value):
        if isinstance(value, bytes):
            value = value.decode("utf-8")
        elif not isinstance(value, str):
            raise TypeError("label must be str or bytes")
        obj.__dict__["_label"] = value

    def __delete__(self, obj):
        obj.__dict__.pop("_label", None)


class Collector:
    label = Utf8Text()


c = Collector()
c.label = b"probe-\xc2\xb5"
print(c.label)          # probe-µ  (decoded on the way in)
print(Collector.label)  # the descriptor itself: obj was None
del c.label
print(vars(c))          # {}

go deeper

for a junior

Be ready to state the definition crisply: a class defining get, set or delete, placed in a class body. Know that reading, writing and deleting the attribute call those three methods.

for a middle

Explain the mechanics: the dunders are looked up on the type, the descriptor must be a class attribute, obj is None on class access, and object.getattribute is what actually calls get.

for a senior

Show judgement about when a descriptor earns its keep over a plain attribute or a method: reusable validation or coercion applied identically across many classes, and the debugging cost of hiding work behind attribute syntax.

for a principal

Own the API-design tradeoff. Attribute syntax that runs code is invisible at the call site, so weigh the ergonomics against readability, testability and the introspection burden it puts on everyone reading the codebase later.

### The definition A **descriptor** is not a special kind of syntax; it is a *protocol*, exactly like iteration. Any class that defines one or more of these three methods on **its own type** is a descriptor: ```python def __get__(self, obj, objtype=None): ... # reading obj.attr def __set__(self, obj, value): ... # writing obj.attr = value def __delete__(self, obj): ... # removing del obj.attr ``` Two conditions must both hold for the protocol to fire. First, the methods live on the **type** of the attribute value, not on the value's instance dictionary — Python looks up dunders on the type, so setting `d.__get__ = f` on an object `d` does nothing. Second, the descriptor object must be reachable as a **class attribute** of the class whose instances you are touching (its own class body or somewhere in its MRO). A descriptor stored in an *instance's* `__dict__` is inert: `obj.attr` finds it and hands it back unchanged. ### What the arguments mean `__get__` receives `(self, obj, objtype=None)` where `self` is the descriptor object, `obj` is the instance the attribute was reached through, and `objtype` is the owning class. When the attribute is reached through the **class** rather than an instance — `Collector.label` — `obj` is `None`. The near-universal convention is to return `self` in that case so that `Collector.label` yields the descriptor for introspection, and only instance access computes a value. `__set__` and `__delete__` always get a real instance, because assigning to a class attribute goes through the metaclass, not through this descriptor. ### Where the call actually happens Attribute access on an ordinary object is implemented by `object.__getattribute__`. It searches `type(obj).__mro__` for the name, checks whether what it found is a descriptor, and if so calls `__get__`; assignment is implemented by `object.__setattr__`, which calls `__set__` when the class attribute defines it and otherwise writes into `obj.__dict__`. So descriptors are not a bolt-on: they are a documented step inside the ordinary attribute machinery, which is why you can bypass them for debugging with `inspect.getattr_static`, which returns the descriptor object itself rather than invoking it. ### Why the protocol exists Descriptors let you attach *behaviour* to what looks to callers like a plain attribute, and to do it **once, per class**, rather than writing the same accessor code in every method that touches the value. Validation, type coercion, unit conversion, lazy computation, logging and read-only enforcement all become a small reusable class you declare in the class body. Crucially, they are also how the language implements itself: plain functions define `__get__`, which is what turns `Collector.sample` into a bound method when you fetch it through an instance; `property`, `classmethod` and `staticmethod` are descriptor types written in C; and `__slots__` generates one descriptor per slot. ### A worked shape ```python class Utf8Text: def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__["_label"] def __set__(self, obj, value): if isinstance(value, bytes): value = value.decode("utf-8") obj.__dict__["_label"] = value ``` A sensor-telemetry collector whose device link sometimes yields `bytes` and sometimes `str` normalises the encoding mismatch at exactly one place — the assignment — rather than at every read site. Callers write `collector.label = raw` and never learn that anything happened. ### The edges worth knowing `__delete__` is spelled that way and is *not* `__del__`: `__del__` is the finalizer that runs when an object is about to be destroyed, while `__delete__` runs on `del obj.attr`. Confusing the two is a classic slip. Raising `AttributeError` from `__get__` is meaningful rather than merely fatal: it is the signal that makes `hasattr` return `False` and triggers the class's `__getattr__` fallback. A descriptor that defines only `__get__` behaves very differently from one that also defines `__set__` when the instance dictionary holds the same name — that precedence rule is the other half of the protocol. And because the descriptor object itself is created once, at class-definition time, any state you store on `self` is shared by every instance of the owner class, which is a bug source rather than a feature. ### One more thing the definition implies Because the protocol is checked on the *type*, descriptors compose with inheritance the way any class attribute does: a subclass inherits the same descriptor object, and rebinding the name in the subclass body replaces it for that branch of the MRO only. And because `__get__` runs arbitrary code, an attribute that looks free can do real work — a network call, a lock acquisition, a lazy import. That is the standing cost of the protocol: it buys clean call sites by making the cost of an attribute invisible, so a descriptor that does anything expensive or blocking deserves a name and a docstring that say so.

  • Why does __get__ take an objtype argument as well as the instance?
    So the descriptor knows which class the lookup started from, and so it can still do something useful when there is no instance. When the attribute is reached through the class, obj is None and objtype is the class, which is how classmethod binds to the class and how a descriptor can return itself for introspection. It also matters under inheritance: objtype tells you the subclass through which the attribute was reached, not the class that owns the descriptor.
  • If I set obj.__dict__['x'] to an object that defines __get__, will it be invoked?
    No. The descriptor protocol only fires for objects found on the type and its MRO. Values pulled out of an instance's __dict__ are returned as-is, with no dunder lookup at all. This is the same reason a function stored on an instance is not turned into a bound method: instance-dict values are data, never descriptors.
  • What is the difference between __delete__ and __del__?
    __delete__ is part of the descriptor protocol and runs when someone executes del obj.attr on an attribute your descriptor manages. __del__ is the finalizer on an ordinary object, called by the interpreter when that object is about to be reclaimed. They are unrelated, and only the spelling is close.

Think of it as a doorman posted at one apartment number in the building's floor plan: every tenant's visit to that number goes through him, and he decides what actually gets handed over.

saying these in an interview costs you the question

  • Says a descriptor is anything with a __get__ attribute, including on an instance
  • Confuses __delete__ with the __del__ finalizer
  • Thinks descriptors must be used with the @property decorator
  • Cannot say what obj is None means inside __get__
  • Believes a descriptor stored in an instance __dict__ is invoked

context