When I first started writing Python, I treated obj.attribute like magic. You write a dot, you get a value, end of story. It took me a genuinely embarrassing amount of time before I realized that dotted lookups are one of the most engineered pieces of machinery in the entire language. Once I understood descriptors, a whole category of Python “magic” — properties, methods, staticmethod, classmethod, even how self gets bound to functions — stopped being magic and became a system I could reason about and build on.
This guide is my attempt to walk you through that system the way I wish someone had walked me through it: starting from the plain mechanics of obj.attr, building up to what a descriptor actually is, and finishing with real code you can run and break on purpose.
What Actually Happens When You Write obj.attribute
Every dotted lookup in Python goes through __getattribute__. For a normal object, the default implementation (inherited from object) follows a specific search order:
- Look in
type(obj).__mro__for a data descriptor with that name. - If not found, look in
obj.__dict__. - If not found, look in
type(obj).__mro__for a non-data descriptor or plain class attribute. - If nothing is found, call
__getattr__if it’s defined, otherwise raiseAttributeError.
That ordering is the whole secret. Instance dictionaries don’t automatically win over class attributes — it depends entirely on what kind of descriptor sits on the class.
What Is a Descriptor?
A descriptor is simply any object whose class defines one or more of these methods:
__get__(self, obj, objtype=None)__set__(self, obj, value)__delete__(self, obj)
If a class defines only __get__, it’s a non-data descriptor. If it defines __set__ or __delete__ (with or without __get__), it’s a data descriptor. This distinction controls priority in step 1 vs step 3 above — data descriptors always win over instance __dict__ entries; non-data descriptors lose to them.
Here’s a minimal descriptor from scratch:
class LoggedAttribute:
def __init__(self, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
print(f"Getting {self.name}")
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
print(f"Setting {self.name} = {value}")
obj.__dict__[self.name] = value
class Person:
age = LoggedAttribute("age")
def __init__(self, age):
self.age = age
p = Person(30)
# Output: Setting age = 30
print(p.age)
# Output: Getting age
# 30
Notice age = LoggedAttribute("age") lives on the class, not the instance. When you write p.age, Python doesn’t just check p.__dict__ — it checks whether Person.age is a data descriptor first. Since LoggedAttribute defines both __get__ and __set__, it is, and it wins every time, even though p.__dict__ also holds an "age" key that the descriptor itself put there.
Why Descriptors Exist: The Property Connection
If you’ve used @property, you’ve used a descriptor without necessarily knowing it. property is a built-in data descriptor class. This:
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def diameter(self):
return self._radius * 2
@diameter.setter
def diameter(self, value):
self._radius = value / 2
is roughly equivalent to defining a class implementing __get__ and __set__ that calls your getter and setter functions. property() is a general-purpose descriptor factory, and once you understand descriptors, you understand exactly why property objects must be defined on the class body rather than assigned inside __init__ — instance attributes can’t be descriptors in the way class attributes can, because the lookup machinery only checks the class’s MRO for descriptor behavior.
Functions Are Descriptors Too
This is the part that rewired my brain a little. Plain functions implement __get__. That’s how methods work.
class Greeter:
def hello(self):
return "hi"
g = Greeter()
print(Greeter.hello) # <function Greeter.hello at 0x...>
print(g.hello) # <bound method Greeter.hello of <...>>
Greeter.hello and g.hello are not the same object. When you access g.hello, Python calls hello.__get__(g, Greeter), and a plain function’s __get__ returns a bound method — a small wrapper object that remembers g so that when you call g.hello(), self is automatically supplied. staticmethod and classmethod are also descriptors, and each customizes __get__ differently: staticmethod.__get__ just returns the underlying function unbound, while classmethod.__get__ binds to the class instead of the instance.
Data vs Non-Data Descriptors: A Practical Test
Functions are non-data descriptors (they only implement __get__). This means, technically, you can shadow a method by assigning to the instance dictionary:
class Demo:
def method(self):
return "class method"
d = Demo()
d.method = lambda: "instance override"
print(d.method()) # instance override
This works precisely because method has no __set__/__delete__, so instance __dict__ wins. Try the same trick with a property, and it fails loudly:
class Demo2:
@property
def value(self):
return 42
d2 = Demo2()
d2.value = 99
# AttributeError: can't set attribute 'value'
property is a data descriptor with a __set__ that raises unless you’ve defined a setter. It always wins over the instance dictionary, so you can’t shadow it that way.
Building a Validating Descriptor
Here’s a practical, reusable pattern — a descriptor that enforces type and range validation, the kind of thing I actually use in real projects instead of repeating boilerplate @property getters and setters everywhere:
class PositiveNumber:
def __init__(self, default=0):
self.default = default
def __set_name__(self, owner, name):
self.name = "_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.name, self.default)
def __set__(self, obj, value):
if not isinstance(value, (int, float)):
raise TypeError(f"{value!r} is not a number")
if value < 0:
raise ValueError("value must be non-negative")
setattr(obj, self.name, value)
class Account:
balance = PositiveNumber()
def __init__(self, balance):
self.balance = balance
a = Account(100)
a.balance = -5
# ValueError: value must be non-negative
__set_name__ is a hook Python calls automatically when the class body finishes executing, telling the descriptor what attribute name it was assigned to. This is what lets one PositiveNumber class be reused across multiple attributes on multiple classes without hardcoding names.
Performance and Internal Notes
Descriptor lookups aren’t free — every dotted access on an instance goes through type.__getattribute__, which walks the MRO looking for descriptors before falling back to __dict__. In CPython this is implemented in C and heavily optimized, but if you’re writing descriptors that do expensive work in __get__, that cost is paid on every single access, not just once. A common optimization is a caching descriptor — a non-data descriptor that computes a value once and then stores it directly in the instance __dict__, so subsequent lookups skip the descriptor entirely (this is exactly how functools.cached_property works).
from functools import cached_property
class Report:
def __init__(self, rows):
self.rows = rows
@cached_property
def total(self):
print("Computing total...")
return sum(self.rows)
r = Report([1, 2, 3])
print(r.total) # Computing total... 6
print(r.total) # 6 (no recomputation)
cached_property is deliberately a non-data descriptor so that once it writes the result into obj.__dict__, the instance dictionary takes priority on future lookups and the descriptor’s __get__ is never called again for that instance.
Common Mistakes
- Storing state on the descriptor instance instead of the object instance. Since a descriptor is defined once on the class and shared by every instance, writing
self.value = valueinside__set__(instead of storing it onobj) causes all instances to share the same value — a bug that’s maddening to track down. - Forgetting the
obj is Nonecheck in__get__. When a descriptor is accessed on the class itself (Person.agerather thanp.age),objisNone. Not handling this causes confusing errors when someone introspects the class. - Assuming instance attributes always override class attributes. As shown above, this is only true for non-data descriptors and plain attributes — data descriptors always win.
FAQs
Q: Are descriptors only useful for library authors? No. Once you’re validating multiple attributes, or logging/caching access, a small custom descriptor removes a lot of duplicated @property code in application-level projects too.
Q: Do descriptors work with __slots__? Yes — in fact, __slots__ entries are themselves implemented as data descriptors under the hood, which is part of how they save memory by avoiding a per-instance __dict__.
Q: Why does obj.__dict__.get(self.name) matter instead of just an attribute lookup? Using obj.__dict__ directly (or __set_name__-derived private names) avoids infinite recursion — if you used getattr(obj, name) inside __get__ for the same name, you’d re-trigger the descriptor and loop forever.
Summary
Dotted lookup in Python isn’t a single mechanism — it’s a small priority system: data descriptors, then instance __dict__, then non-data descriptors and class attributes, then __getattr__. Descriptors are what make property, methods, staticmethod, classmethod, and cached_property possible, and understanding them turns a lot of “just how Python works” into things you can implement yourself for validation, logging, caching, and cleaner APIs.
