Articles
Python Descriptor Interview Questions: Attribute Lookup, Properties, and Precedence
Prepare for Python descriptor interview questions with lookup traces, property precedence, per-instance storage, and tested attribute-collision examples.
Python descriptor interview questions become easier when you trace attribute lookup instead of memorizing decorators. For ordinary instance reads, a readable data descriptor takes priority over the instance dictionary; an instance entry takes priority over a non-data descriptor. That difference explains why a property can resist shadowing while an ordinary method can be replaced on one object. The class hierarchy determines which class binding enters that decision.
Imagine a model whose validated field returns the wrong value after a developer edits obj.__dict__. Before blaming the validator, ask where the descriptor lives, whether it defines a setter, and which class supplied the attribute. This guide uses an executable collision puzzle and a reusable validation field to make those questions concrete. For the wider language context, see Python Interview Questions for Software Engineers.
Evidence boundary: Official facts about attribute access come from Python documentation linked below. Candidate reports: no verified report establishes this exact exercise or an employer's question frequency. Inference: the examples, debugging sequence, and interview recommendations are original practice material. Execution checks cover the supplied fixtures, not every metaclass or framework customization.
What is a descriptor, and where must it live?
A descriptor is an object whose type defines attribute-management methods such as __get__, __set__, or __delete__. When that object is a class attribute, Python's standard attribute machinery can invoke those methods for access through an instance or class. Putting the same object directly in an instance dictionary does not automatically activate the protocol.
Official fact: Python's Descriptor Guide describes this protocol and class-level placement. In the examples here, the descriptor object is shared configuration; the application instances are the objects whose fields it manages. Confusing those two roles is a common source of incorrect storage designs.
For obj.field, the descriptor's __get__ receives the descriptor as self, the application object as obj, and its owner class as the final argument. Through Model.field, obj is normally None. Returning the descriptor itself for that class access is a useful convention for inspection, but it is not mandatory behavior for every descriptor.
Ask what contract the field needs before reaching for the protocol. A single computed attribute may need only property. Repeated validation across many model classes may justify a descriptor. A plain stored value with no access policy usually does not need either abstraction.
What is the attribute lookup order?
For the normal object.__getattribute__ path, first find the name's first binding in the class's method resolution order, or MRO. That binding may be inherited. Then determine how it interacts with instance storage.
| Candidate | Position in an ordinary instance read |
|---|---|
| Readable data descriptor from the selected class binding | Before the instance dictionary |
| Instance dictionary entry | Before a non-data descriptor or ordinary class value |
| Non-data descriptor from the selected class binding | Its __get__ runs if the instance has not supplied the name |
| Ordinary class value | Returned if no earlier candidate wins |
| Missing attribute | Normal dot access can invoke __getattr__ after lookup raises AttributeError |
Official fact: The Python data model specifies descriptor invocation and attribute-access customization. A descriptor with __set__ or __delete__ is classified as a data descriptor; a get-only descriptor is non-data. The table concerns reads through readable descriptors, not a promise that a set-only descriptor computes a read value.
Do not start by searching every base class for the “strongest” descriptor. Resolve the class binding first. If a subclass defines the same name as an ordinary value, that selected binding can hide a base-class descriptor. Also state the scope of the table: an overridden __getattribute__ can alter the normal lookup behavior.
Predict the collision puzzle before running it
This original fixture deliberately writes colliding names into an instance dictionary. It lets you separate lookup priority from whatever a framework normally permits through assignment.
class ReadOnly:
def __get__(self, obj, owner=None):
return self if obj is None else "computed"
def __set__(self, obj, value):
raise AttributeError("read only")
class Fallback:
def __get__(self, obj, owner=None):
return self if obj is None else "fallback"
class Model:
strict = ReadOnly()
loose = Fallback()
def action(self):
return "method"
m = Model()
m.__dict__.update(strict="stored", loose="stored")
assert m.strict == "computed"
assert m.loose == "stored"
assert m.action() == "method"
m.action = lambda: "replacement"
assert m.action() == "replacement"
assert Model.action(m) == "method"
strict wins because its type supplies both the read hook and a setter. The setter's refusal to accept writes does not make the descriptor non-data. loose loses because the instance already owns that name. Deleting m.__dict__["loose"] would expose the fallback again.
The method example shows that descriptors are not limited to custom field classes. Ordinary Python functions stored on classes support binding. Access through an instance produces a bound method when no instance attribute shadows the name. Our replacement lambda lives on the instance, so it is returned directly; Python does not automatically bind an extra self argument to it.
The explicit Model.action(m) call still reaches the class function. Replacing an instance's attribute did not edit the class. In an interview, explain that difference before talking about mocking, patching, or monkey-patching: the affected object and lookup path determine the scope of the change.
Local verification: the assertions above were executed. The exercise demonstrates these specific paths under ordinary attribute access; it does not imply that bypassing a model's public setter is a supported application operation.
Why does a property behave differently from cached_property?
A getter-only property remains a data descriptor. Assigning through that property raises an error when no setter is supplied; placing a same-name entry in an available instance dictionary does not override its getter. Official fact: the property documentation documents managed access and optional setters and deleters.
For a small example, make total a property returning subtotal + tax, then insert total=0 into the instance dictionary. Reading obj.total still invokes the getter. This explains why “but I can see the value in vars(obj)” is not enough to predict a read. Storage inspection and attribute resolution are different observations.
By contrast, official documentation for functools.cached_property describes computing a value and storing it under the same instance attribute name. Later reads use that stored value; assignment can replace it, and deletion allows recomputation. The instance needs a suitable mutable __dict__.
Choose between those contracts deliberately. A cached exchange-rate conversion can become stale when its inputs change. A property that recomputes on every access may be expensive but avoids that particular cache invalidation problem. Neither choice establishes that the underlying calculation is thread-safe. Since Python 3.12, cached_property does not include its former per-property lock; do not promise once-only evaluation under concurrency without your own synchronization.
How do you keep values separate for each instance?
A descriptor stored on a class is reused by instances of that class. If its setter writes self.value = value, every object using that descriptor can observe the same last value. That may be intentional shared configuration, but it is usually wrong for a person's age or a job's retry limit.
Our original Limit descriptor stores each value on the managed instance. It also distinguishes an unset field from an invalid assignment.
class Limit:
def __set_name__(self, owner, name):
self.storage = "_" + name
def __get__(self, obj, owner=None):
if obj is None:
return self
try:
return obj.__dict__[self.storage]
except KeyError:
raise AttributeError(self.storage) from None
def __set__(self, obj, value):
if type(value) is not int or value < 0:
raise ValueError("expected a nonnegative int")
obj.__dict__[self.storage] = value
class Job:
retries = Limit()
first, second = Job(), Job()
first.retries, second.retries = 2, 5
assert (first.retries, second.retries) == (2, 5)
assert Job.retries is vars(Job)["retries"]
The exact type(value) is int policy deliberately rejects booleans and integer subclasses. That is a domain decision, not a universal validation rule. If subclasses should be accepted, define and test that requirement separately rather than casually changing the predicate.
The underscore storage name is also a convention, not a security boundary. Application code can modify it directly. This implementation requires an instance dictionary and therefore does not work unchanged for a slots-only class. It is an interview-sized design with stated assumptions, not a drop-in ORM field.
Explain why the getter converts a missing storage key to AttributeError. Attribute consumers expect missing attributes to follow the attribute protocol. A raw KeyError would expose the implementation detail instead. Conversely, do not turn every exception in a getter into AttributeError; an actual calculation failure should not masquerade as an absent field.
What changes under inheritance and custom lookup?
Consider a subclass of Model that sets strict = "subclass value". An ordinary read on its instance selects that subclass binding before considering the base's ReadOnly object. If the instance then stores its own strict, that instance value can win over the selected ordinary class value. The base descriptor is not globally privileged simply because it is a data descriptor.
Name initialization is another boundary. During class creation, __set_name__ lets a field record the name it was assigned. Assigning a new descriptor to an already-created class does not automatically perform that notification; code attaching it dynamically must handle initialization. Reusing one stateful descriptor under several names can also overwrite its recorded name. Use distinct descriptor instances when each field needs distinct configuration.
An override of __getattribute__ should normally delegate deliberately, for example to super().__getattribute__, when it wants standard behavior. A shortcut that returns entries from self.__dict__ can bypass descriptors and may recurse while trying to read __dict__ itself. Trace the override before applying the usual precedence answer.
For inspection without executing access hooks, Python provides inspect.getattr_static. Official fact: it avoids dynamic lookup behavior such as descriptor execution, with documented limitations. In a debugger, that distinction matters when an innocent-looking field read could run a database query or raise an exception.
What would you test and explain in an interview?
Test collisions as well as ordinary reads. The interesting failure is often two legal storage locations competing for the same name. Our verification extends the fixtures with an unset field, a rejected negative assignment, a rejected boolean, distinct instance values, class access, and subclass shadowing. Each case protects a different part of the contract.
Also test failure state. If first.retries was two before a rejected assignment, it should still be two afterward. Validation must happen before storing the new value. A getter-only test would miss that requirement even if every successful read looked correct.
| Practice question | How to use it for this topic |
|---|---|
| Python Language and Runtime Fundamentals | Explain attribute access with a concrete lookup trace |
| Implement Python feature and resolver metaprogramming | Reason about class configuration and dynamic behavior |
| Compare Functional and Object-Oriented Python Design | Decide whether a managed field is worth the abstraction |
| Design employee and company classes with tests | Separate per-instance state and shared configuration |
| Discuss Python mutability, copying, and GIL | Connect attribute storage to object sharing and mutation |
Start with Implement Python feature and resolver metaprogramming. Before writing an abstraction, state where its configuration and each instance's values live. Then predict what happens when the same name appears in both places.