Python attribute lookup order: why a data descriptor beats the instance __dict__, and when __set_name__ runs
obj.x checks data descriptors before the instance __dict__. How __set__ or __delete__ sets the order, why cached_property can be overwritten, and why late-added descriptors miss __set_name__.
You can put a value straight into obj.__dict__["x"] and still have obj.x ignore it. Yet an attribute made with functools.cached_property is replaced as soon as you assign the same name on the instance. Both are descriptors. What separates them is whether the descriptor defines __set__ (or __delete__).
This post is based on the Python 3.14 documentation (pages labelled 3.14.8): the data model reference, the Descriptor Guide and the functools page, all checked on 2026-10-19. The example output was not produced by running the code; it was written down by applying the documented lookup rules to the code line by line. The lookup order described here is for instances that use the default object.__getattribute__(). A class that overrides __getattribute__() can change or bypass this order, although an override that delegates to object.__getattribute__() can keep it.
Descriptors, and the two kinds
The data model reference calls an object a descriptor if it defines any of __get__(), __set__() or __delete__(). If it defines __set__() or __delete__(), it is a data descriptor; if it defines neither, it is a non-data descriptor.
Descriptors only work as class variables. The Descriptor Guide says that a descriptor put in an instance has no effect. So the lookup order below is about which wins: a value in the instance __dict__, or a descriptor on the class (or a parent class in the MRO).
The instance lookup order
The Descriptor Guide sums up instance lookup like this: data descriptors have the highest priority, followed by instance variables, then non-data descriptors, then class variables, and lastly __getattr__() if it is defined.
The same page has a pure Python version of object_getattribute(). Condensed, it reads:
- Look the name up on the class side, following
type(obj).__mro__. - If the type of what was found has
__get__and also__set__or__delete__(a data descriptor), call__get__(obj, type(obj))and return the result. The instance__dict__is never consulted. - Otherwise, if the name is in the instance
__dict__, return that value. - If the class-side value is a non-data descriptor, call its
__get__. - If it is a plain class variable, return it.
- If none of these applies, raise
AttributeError.
__getattr__() is not part of that function. According to the same page, the dot operator and getattr() call __getattr__() when __getattribute__() raises AttributeError. That is why calling __getattribute__() directly skips __getattr__().
The data model reference reaches the same conclusion. Data descriptors that define __get__() and __set__() (and/or __delete__()) always override a redefinition in an instance dictionary, while non-data descriptors can be overridden by instances. Plain methods, @staticmethod and @classmethod are non-data descriptors, and @property is a data descriptor. That is why an instance can override a method but not the behaviour of a property.
The example
One file covers everything in this post. Field is a data descriptor with __set__: it accepts only integers and stores the value on the instance under _<name>. Lazy is a non-data descriptor with only __get__: on the first read it computes the value and puts it in the instance __dict__ under the same name. It works along the lines of functools.cached_property, simplified for the explanation.
class Field:
def __set_name__(self, owner, name):
print(f"__set_name__({owner.__name__}, {name!r})")
self.name = name
self.storage = "_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.storage)
def __set__(self, obj, value):
if not isinstance(value, int):
raise TypeError(f"{self.name} must be int")
setattr(obj, self.storage, value)
class Lazy:
def __init__(self, func):
self.func = func
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
print(f"computing {self.name}")
value = self.func(obj)
obj.__dict__[self.name] = value
return value
print("defining Order")
class Order:
qty = Field()
@Lazy
def label(self):
return f"order x{self.qty}"
def __init__(self, qty):
self.qty = qty
print("== 1 lazy")
o = Order(3)
print(o.label)
print(o.label)
print(vars(o))
print("== 2 data descriptor vs __dict__")
o.__dict__["qty"] = "bad"
print(o.qty)
try:
o.qty = "x"
except TypeError as e:
print("TypeError:", e)
print("== 3 non-data descriptor vs __dict__")
o.label = "manual"
print(o.label)
del o.label
print(o.label)
print("== 4 added after class creation")
late = Field()
Order.extra = late
try:
o.extra = 1
except AttributeError as e:
print("AttributeError:", e)
late.__set_name__(Order, "extra")
o.extra = 1
print(o.extra)
Following those rules, the output is as below. The exact wording of the final AttributeError message may differ between interpreter versions.
defining Order
__set_name__(Order, 'qty')
== 1 lazy
computing label
order x3
order x3
{'_qty': 3, 'label': 'order x3'}
== 2 data descriptor vs __dict__
3
TypeError: qty must be int
== 3 non-data descriptor vs __dict__
manual
computing label
order x3
== 4 added after class creation
AttributeError: 'Field' object has no attribute 'storage'
__set_name__(Order, 'extra')
1
set_name runs once, when the class is created
__set_name__(Order, 'qty') is printed before any instance exists, while the class Order: statement runs. The data model entry for __set_name__ says it is called automatically when the owning class is created, with that class and the name the object was assigned to. It was added in 3.6.
According to the section on creating the class object, with the default metaclass type, or any metaclass that ultimately calls type.__new__, type.__new__ does the following after creating the class object: it collects the attributes in the namespace that define __set_name__, calls each one with the class and that attribute’s name, and then calls __init_subclass__() on the immediate parent in the MRO. The Descriptor Guide adds that __set_name__ is called even when the object is not a descriptor.
This is how Field learns the attribute name without being written as qty = Field("qty").
1. A non-data descriptor computes once
On the first read of o.label there is no label in the instance __dict__, so the non-data descriptor Lazy.__get__ runs and prints computing label. Because it stored the result in o.__dict__["label"], the second read is answered at step 3, from the instance __dict__. That is why computing label appears only once.
According to its documentation, functools.cached_property behaves the same way. It runs only on lookups, and only when an attribute of the same name doesn’t exist. When it runs it writes to the attribute with that name, and later reads and writes work like a normal attribute. Deleting the attribute lets it run again. The same page says each instance’s __dict__ must be a mutable mapping, so it does not work on classes that define __slots__ without __dict__. Since 3.12 a previously undocumented lock is gone, so the getter can run more than once on the same instance when several threads use it.
2. A data descriptor ignores dict
Putting qty directly into the instance dictionary with o.__dict__["qty"] = "bad" doesn’t change o.qty, which is still 3. Field is a data descriptor, so Field.__get__ is called first at step 2, and it reads _qty. "bad" is still there in vars(o), but o.qty doesn’t show it.
Assignment works the same way. o.qty = "x" doesn’t write to the instance __dict__; it goes through Field.__set__ and raises TypeError. A property without a setter blocks writes through the same mechanism. The Descriptor Guide says a read-only data descriptor is one whose __set__() raises AttributeError, and that defining a __set__() that only raises is enough to make it a data descriptor.
3. An instance overrides a non-data descriptor
Lazy has no __set__, so o.label = "manual" goes straight into the instance __dict__, and o.label is manual from then on. Removing that entry with del o.label means the next read calls Lazy.__get__ again and prints computing label.
Functions are the same case. The Descriptor Guide explains that functions have a __get__() method so they can produce bound methods, which makes them non-data descriptors. Assigning an attribute with a method’s name on an instance hides the method for that instance only.
4. A descriptor added later doesn’t know its name
If you assign a descriptor after the class exists, as in Order.extra = late, __set_name__ is not called automatically. Both the data model reference and the Descriptor Guide state this and say to call it yourself if needed. In the example, late has no storage yet, so o.extra = 1 ends in AttributeError. After calling late.__set_name__(Order, "extra") by hand, it works.
Field gets past the integer check for 1 and fails only when it reads self.storage. If your code attaches a descriptor that learns its name from __set_name__ after the class has been created, with setattr() or similar, call __set_name__ at the same spot. That last sentence is advice drawn from the behaviour above, not something the documentation says.
Don’t store the value on the descriptor
If Field stored the value on itself, as self.value, every instance would see the same value, because the class has only one descriptor. That’s why the example uses the name it got from __set_name__ to keep the value on each instance. It is the same shape of problem as why L=[] as a Python default argument keeps growing: one object shared by everyone who uses it. This section explains the example’s design; the sharing is a deduction from the documented fact that descriptors are class variables.
References
All checked on 2026-10-19. The pages are labelled 3.14.8.