Descriptors and Dotted Lookups in Python: Complete Attribute Access and Property Management Guide

Descriptors and Dotted Lookups in python

Descriptors and Dotted Lookups in python

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:

  1. Look in type(obj).__mro__ for a data descriptor with that name.
  2. If not found, look in obj.__dict__.
  3. If not found, look in type(obj).__mro__ for a non-data descriptor or plain class attribute.
  4. If nothing is found, call __getattr__ if it’s defined, otherwise raise AttributeError.

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:

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

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.

References

Exit mobile version