Attribute Lookup Chain — Core Concepts

The Full Lookup Order

When you write obj.attr in Python, the interpreter follows a precise sequence to find the value. This isn’t a simple parent-child search — there are five steps, and the order matters:

  1. Data descriptors on the class (and its parents) — checked first.
  2. Instance __dict__ — the object’s own attributes.
  3. Non-data descriptors and other class attributes — methods, class variables.
  4. __getattr__ — a fallback hook if nothing was found.
  5. AttributeError — if none of the above found anything.

What Are Descriptors?

A descriptor is any object that defines __get__, __set__, or __delete__. They live on the class, not the instance, and they intercept attribute access.

There are two kinds:

  • Data descriptors: Define both __get__ and __set__ (or __delete__). They take priority over instance attributes.
  • Non-data descriptors: Define only __get__. Instance attributes override them.

The Most Common Descriptors

DescriptorTypeYou Know It As
propertyData@property decorator
FunctionsNon-dataRegular methods
classmethodNon-data@classmethod
staticmethodNon-data@staticmethod
__slots__ entriesDataMemory-optimized attributes

Why Data Descriptors Win

The @property decorator creates a data descriptor. When you access obj.attr and there’s a property named attr on the class, the property’s __get__ method runs — even if obj.__dict__ also has an attr key.

This is by design. Properties are meant to control access to an attribute. If instance attributes could override them, properties would be unreliable.

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

Here, radius is a data descriptor. Even if you sneak a radius key into obj.__dict__, the property still intercepts access.

Instance Dict vs. Class Dict

Every regular Python object has a __dict__ — a dictionary holding its attributes:

class Dog:
    species = "Canis familiaris"  # Class attribute (in Dog.__dict__)

    def __init__(self, name):
        self.name = name  # Instance attribute (in self.__dict__)
  • Dog.__dict__ contains species and __init__.
  • fido.__dict__ contains name.
  • fido.species works because after checking fido.__dict__ and not finding species, Python checks Dog.__dict__.

The __getattr__ Fallback

If the normal lookup fails to find the attribute, Python calls __getattr__ (if defined). This is your last chance to provide a value:

class Flexible:
    def __getattr__(self, name):
        if name.startswith("get_"):
            field = name[4:]
            return lambda: self.__dict__.get(field, None)
        raise AttributeError(f"No attribute '{name}'")

Important: __getattr__ only runs when normal lookup fails. There’s also __getattribute__, which runs on every attribute access — but overriding it is risky and rarely needed.

Common Misconception

“Instance attributes always win.” They don’t. Data descriptors (properties, slots) beat instance attributes. This surprises developers who set obj.__dict__['attr'] directly and find that a property still intercepts their access.

Quick Reference: The Lookup in Order

StepCheckedExample
1Data descriptor on class/parents@property, __slots__
2Instance __dict__self.x = 10
3Non-data descriptor / class attrMethods, @classmethod, class variables
4__getattr__Custom fallback
5AttributeErrorNothing found

Practical Implications

  • Setting an attribute with obj.attr = value goes into obj.__dict__ unless a data descriptor’s __set__ intercepts it.
  • Deleting an attribute with del obj.attr removes it from obj.__dict__ unless a data descriptor’s __delete__ intercepts it.
  • hasattr(obj, 'x') follows the full lookup chain, including __getattr__.

One thing to remember: Python’s attribute lookup is a five-step process where data descriptors beat instance attributes, instance attributes beat class attributes, and __getattr__ is the last resort before an error.

pythonadvancedoopinternals

See Also