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.
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:
- A data descriptor found on the type — one that defines
__set__or__delete__— is invoked, and it wins outright. - Otherwise the instance's
__dict__is consulted. - Otherwise a non-data descriptor on the type is invoked, or a plain class attribute is returned.
- 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
- A long-running Python service keeps climbing in memory and never comes back down. How do you work out where the memory went?hardAlso on slots5 min
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardSame kind of round: concept7 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardSame kind of round: concept6 min
- How do you decide between BFS and DFS - and how do you recognise a graph problem that nobody described as a graph?mediumSame kind of round: coding4 min
- How do goroutines leak, and how would you find a leak in a running service?mediumSame kind of round: concept5 min
- Your function returns nil on the success path, the caller checks err != nil, and the check fires anyway. What happened?hardSame kind of round: concept4 min
- Design rejected the native control, so you are building a custom one. How do you make it accessible?hardSame kind of round: concept5 min
- A client hangs up halfway through a request. How do you structure a Go HTTP service so it stops doing the work?mediumSame kind of round: concept7 min