Skip to content
QSWEQB
hardConceptCodingMidSeniorStaff

What is a descriptor, and how does @property use one?

A descriptor is a class attribute defining __get__, and optionally __set__ or __delete__, that intercepts attribute access on instances. property is one. Whether it defines __set__ decides whether it beats the instance dictionary, which is the whole of attribute-lookup precedence.

5 min readUpdated 2026-07-26

What the interviewer is scoring

  • Whether the data versus non-data distinction is tied to __set__ and used to explain lookup order
  • Does the candidate know that a plain function is a descriptor and that this is where bound methods come from
  • That per-instance state is kept in the instance dictionary rather than on the descriptor object
  • Can they say when __getattr__ is the right tool and why __getattribute__ recurses if written carelessly
  • Whether __slots__ is recognised as generating descriptors rather than as a memory flag

Answer

Attribute access is a lookup with rules

obj.x is not a dictionary read. It calls type(obj).__getattribute__(obj, "x"), and the default implementation searches the type's MRO before it looks at the instance. If what it finds on the type is an object defining __get__, it calls that instead of returning it. Such an object is a descriptor, and the entire mechanism sits in the order those steps happen:

  1. A data descriptor found on the type — one that defines __set__ or __delete__ — is invoked, and it wins outright.
  2. Otherwise the instance's __dict__ is consulted.
  3. Otherwise a non-data descriptor on the type is invoked, or a plain class attribute is returned.
  4. If every step raised AttributeError, __getattr__ is called as a last resort, if the class defines one.

@property is a data descriptor. That is why you cannot shadow a property by assigning to the instance — step one fires before step two ever runs, and if the property has no setter you get AttributeError instead of a new instance attribute. It is also why the answer "a property is just a getter" is incomplete: what makes it behave like a property, rather than like a value someone can overwrite, is that it defines __set__ at all.

Bound methods are the same machinery

The detail that makes descriptors feel less exotic is that you have been using them since your first class. A plain function defines __get__, and looking one up through an instance calls it, returning a bound method that has captured self. Reach the same function through the class and no instance is involved, so you get the underlying function.

class Greeter:
    def hello(self):
        return "hi"

print(Greeter.hello)                       # <function Greeter.hello at ...>
print(Greeter().hello)                     # <bound method Greeter.hello of ...>
print(Greeter.hello.__get__(Greeter())())  # 'hi' - what the dot does for you

staticmethod and classmethod are descriptors too, differing only in what they pass along. A function is a non-data descriptor, which is precisely why an instance attribute can hide a method: assign obj.hello = lambda: "shadowed" and step two of the lookup finds it before step three ever considers the class.

Writing one, and where the state goes

Reach for a custom descriptor when the same access rule repeats across several attributes or several classes, which is the case property handles clumsily because it wants a pair of methods per attribute. __set_name__, added in Python 3.6, is called at class creation with the name the descriptor was assigned to, which removes the old boilerplate of passing the name in twice.

from functools import cached_property


class Positive:
    """Defines __set__, so it is a data descriptor and outranks the instance dict."""

    def __set_name__(self, owner, name):
        self.storage = "_" + name          # derived once, at class creation

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self                    # reached through the class, not an instance
        return getattr(obj, self.storage)

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self.storage[1:]} must be positive")
        setattr(obj, self.storage, value)  # per-instance state lives on the instance


class Order:
    quantity = Positive()

    def __init__(self, quantity):
        self.quantity = quantity           # routed through Positive.__set__

    @cached_property
    def total(self):
        print("computing")
        return self.quantity * 100


order = Order(3)
print(order.total)                                  # computing, then 300
print(order.total)                                  # 300, no recomputation
print("total" in order.__dict__)                    # True - it cached itself there
order.__dict__["quantity"] = -5                     # written round the descriptor
print(order.quantity)                               # 3 - the data descriptor still wins

The mistake to name is storing the value on the descriptor instead of the instance. A descriptor is created once, in the class body, so self.value = value inside __set__ makes every instance of Order share one quantity. It looks like it works in a unit test with a single object and corrupts data as soon as there are two.

The same listing shows why cached_property behaves differently. It defines only __get__, making it a non-data descriptor, and its __get__ writes the computed value into the instance's __dict__ under its own name. Step two of the lookup then satisfies every later access and the descriptor is never consulted again — a caching strategy that costs nothing per read, at the price of being invalidated only by deleting the dictionary entry yourself. Two consequences follow. It needs a mutable __dict__, so a class with __slots__ and no dictionary raises TypeError. And since Python 3.12 it no longer holds a lock while computing, a change made because the shared lock serialised unrelated instances; concurrent first accesses may now compute the value more than once, which is fine for a pure function and wrong for anything with a side effect.

Slots, and the two catch-alls

__slots__ is descriptors again. Declaring it generates one slot descriptor per name, each reading and writing a fixed offset in the instance rather than a dictionary, and suppresses __dict__ entirely. That is where the memory saving comes from, and it is also why a slotted class cannot carry a class attribute with the same name as a slot: the assignment in the class body would replace the descriptor the mechanism depends on.

__getattr__ and __getattribute__ are the two hooks people confuse. __getattr__ runs only after normal lookup has failed, which makes it the right tool for a genuinely dynamic namespace — a settings proxy, a lazy module shim, an RPC stub that turns any name into a remote call. __getattribute__ runs on every access, including the ones that would have succeeded, so it can implement things nothing else can and is easy to break: reading self.anything inside it re-enters it and recurses until the stack ends. Delegate to super().__getattribute__(name) rather than touching the instance directly, and prefer __getattr__ unless you specifically need to intercept successful lookups.

Whether a descriptor defines __set__ is not a detail — it decides whether the class or the instance owns the name, which is the difference between a property nobody can overwrite and a cached value that overwrites itself on first read.

Likely follow-ups

  • Why can an instance attribute shadow a cached_property but not a property?
  • What does __set_name__ save you from writing, and what did people do before it?
  • Why does cached_property fail on a class that defines __slots__?
  • When is a descriptor a better answer than validating in __init__ or __post_init__?

Related questions

Further reading

descriptorspropertyattribute-lookupslotscached-property