The dot in order.total looks like punctuation. It is closer to a protocol dispatch. Depending on what Python finds, the expression can read an instance dictionary, walk a class's method resolution order, call user code in a descriptor, manufacture a bound method, or invoke a fallback hook.

That machinery is not an exotic corner reserved for framework authors. property, methods, classmethod, staticmethod, __slots__, many ORM fields, and cached attributes all participate in it. A working model of lookup turns surprising behavior into a precedence question.

Version note. The behavioral rules here apply to Python 3.10 through 3.14 and the experiments were verified on CPython 3.14.7. CPython bytecode, internal type slots, caches, and C function names are implementation details and can change between releases.

Experiment 1: the object and its type both contribute

Predict both lines before running this:

Pyodide / WebAssembly
class Service:
    timeout = 30


first = Service()
second = Service()
first.timeout = 5

print("[result] first and second timeout:", first.timeout, second.timeout)
print("[state] first instance state and class timeout:", first.__dict__, Service.__dict__["timeout"])

It prints:

5 30
{'timeout': 5} 30

Assignment to first.timeout created an instance attribute. It did not edit Service.timeout, so second still reaches the class value. For ordinary instance lookup, Python considers both per-instance state and attributes found by searching the type's method resolution order (MRO).

An initial approximation is therefore "instance, then class." It is useful, but incomplete. The value found in a class may participate in the descriptor protocol and change the order.

Language guarantee. Attribute references use the object's type and its customization hooks. Class inheritance follows the C3 MRO exposed as type(obj).__mro__. An instance dictionary is common, not universal: slotted and extension types may store state differently.

The precedence ladder

For an ordinary instance obj and name x, the durable model is:

  1. Call type(obj).__getattribute__(obj, "x").
  2. Search type(obj).__mro__ for a class attribute named x.
  3. If that class attribute is a data descriptor, invoke it.
  4. Otherwise, if the instance has its own x, return that value.
  5. Otherwise, if the class attribute is a non-data descriptor, invoke it.
  6. Otherwise, return the plain class attribute.
  7. If normal lookup raises AttributeError, dotted access may call __getattr__.

A descriptor is an object whose type supplies __get__, __set__, or __delete__. A descriptor defining __set__ or __delete__ is a data descriptor. One defining only __get__ is non-data. That small difference decides whether instance state can shadow it.

This is a semantic model, not a promise that an implementation literally performs seven dictionary operations on every access. Implementations are free to optimize while preserving observable behavior.

Experiment 2: a non-data descriptor can be shadowed

Predict whether the second read still calls __get__:

Pyodide / WebAssembly
class Label:
    def __get__(self, obj, owner=None):
        print("[event] descriptor read")
        return "from class"


class Item:
    name = Label()


item = Item()
print("[result] first name lookup:", item.name)
item.__dict__["name"] = "from instance"
print("[result] shadowed name lookup:", item.name)

The first read calls the descriptor. The second prints from instance without calling it. Label only defines __get__, so it is a non-data descriptor and the instance dictionary has priority.

This precedence makes lazy caching possible. A non-data descriptor computes a value once, writes it under the same name in the instance dictionary, and is naturally bypassed on later reads. functools.cached_property follows that broad pattern and consequently requires a mutable instance __dict__.

Descriptors only gain this automatic role when found through a class. Putting Label() directly in item.__dict__ merely stores an ordinary value; Python does not recursively ask arbitrary instance values whether they are descriptors.

Experiment 3: a data descriptor wins

Now add __set__. Predict which of the two stored strings is returned:

Pyodide / WebAssembly
class Managed:
    def __get__(self, obj, owner=None):
        return obj.__dict__["_status"]

    def __set__(self, obj, value):
        obj.__dict__["_status"] = value.upper()


class Job:
    status = Managed()


job = Job()
job.status = "queued"
job.__dict__["status"] = "instance decoy"

print("[state] job instance dictionary:", job.__dict__)
print("[result] managed status:", job.status)

The dictionary contains both '_status': 'QUEUED' and 'status': 'instance decoy', but job.status returns QUEUED. Defining __set__ made Managed a data descriptor, which outranks the same-named instance entry.

property is a familiar data descriptor. Even a read-only property has descriptor assignment behavior that raises AttributeError; writing the same key directly into __dict__ does not override the property read.

This distinction matters in libraries. A non-data descriptor permits per-instance replacement and caching. A data descriptor enforces mediation of reads and writes. Choose deliberately rather than adding a placeholder __set__ without recognizing that it changes precedence.

Experiment 4: class access is a different call

A descriptor may be read through an instance or through its owner class. Predict the values of obj in these two cases:

Pyodide / WebAssembly
class Field:
    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, owner=None):
        if obj is None:
            return f"field {owner.__name__}.{self.name}"
        return obj.__dict__[self.name]


class Record:
    code = Field()


record = Record()
record.__dict__["code"] = "A17"

print("[result] class-level field:", Record.code)
print("[result] instance field value:", record.code)
print("[state] raw descriptor name:", vars(Record)["code"].name)

This prints field Record.code, A17, and code. Class access invokes __get__(None, Record); instance access invokes __get__(record, Record). A well-behaved descriptor commonly returns itself, or useful field metadata, when obj is None.

__set_name__ is called during class creation for attributes that define it, whether or not they are otherwise descriptors. It lets one reusable descriptor learn its assigned name. If code attaches a descriptor after class creation, the automatic notification has already passed; call __set_name__ explicitly or design another initialization path.

Using vars(Record)["code"] reads the raw class namespace and avoids descriptor invocation. That is useful for inspection and testing.

Experiment 5: functions bind themselves

Why does a method receive self even though the call only supplies one explicit argument?

class Greeter:
    def greet(self, name):
        return f"hello {name} from {type(self).__name__}"


greeter = Greeter()
bound = greeter.greet

print(bound.__self__ is greeter)
print(bound.__func__ is Greeter.__dict__["greet"])
print(bound("Ada"))
print(Greeter.greet(greeter, "Ada"))

All comparisons are true, and the final two calls return the same text. A user-defined function stored on a class is a non-data descriptor. Access through an instance returns a bound method containing the original function in __func__ and the instance in __self__. Calling it inserts that instance before the explicit arguments.

Because functions are non-data descriptors, an instance attribute can shadow a method:

Pyodide / WebAssembly
class Greeter:
    def greet(self, name):
        return f"hello {name}"


one_greeter = Greeter()
one_greeter.greet = lambda name: f"intercepted {name}"
print("[result] instance-shadowed greeting:", one_greeter.greet("Ada"))
# intercepted Ada

That can support intentional per-instance behavior, but accidental assignments silently replace methods. A property or slotted design can prevent it when replacement violates the object's contract.

Language guarantee. User-defined functions retrieved from a class instance become bound methods. The exact method object's representation, allocation strategy, and CPython fast-call optimizations are not part of that guarantee.

Experiment 6: the MRO chooses the descriptor first

Lookup does not merge same-named definitions from several bases. Predict which descriptor runs:

Pyodide / WebAssembly
class Trace:
    def __init__(self, label):
        self.label = label

    def __get__(self, obj, owner=None):
        return self.label


class Left:
    token = Trace("left")


class Right:
    token = Trace("right")


class Child(Left, Right):
    pass


print("[state] Child MRO:", [cls.__name__ for cls in Child.__mro__])
print("[result] selected token:", Child().token)

Child's MRO places Left before Right, so lookup finds and invokes Left.token. It does not continue to compare descriptor kinds after finding a class attribute. Cooperative methods use super() to continue from a particular point in the MRO; super() is not simply "my parent class."

For debugging, inspect type(obj).__mro__ and then use vars(cls).get(name) on each class. hasattr and ordinary getattr execute descriptor and fallback code, so they can hide where a value originated or trigger side effects.

Experiment 7: __getattr__ is fallback, not interception

Predict how many lookups print fallback:

Pyodide / WebAssembly
class Settings:
    region = "eu-west"

    def __init__(self):
        self.retries = 3

    def __getattr__(self, name):
        print("[event] fallback lookup:", name)
        if name.startswith("feature_"):
            return False
        raise AttributeError(name)


settings = Settings()
print("[result] instance retries:", settings.retries)
print("[result] class region:", settings.region)
print("[result] missing feature default:", settings.feature_dark_mode)

Only the missing feature name reaches __getattr__. Existing instance and class attributes are handled first. If the fallback cannot provide a value, it should raise AttributeError, not return None; callers such as hasattr depend on that signal.

There is a sharper failure mode: if a property getter or descriptor internally raises AttributeError, Python may treat the whole attribute lookup as missing and invoke __getattr__. A fallback can therefore conceal a bug inside a descriptor.

Pyodide / WebAssembly
class Profile:
    @property
    def display_name(self):
        return self.account.name  # account was never set

    def __getattr__(self, name):
        return f"<missing {name}>"


print("[result] concealed display-name failure:", Profile().display_name)
# <missing display_name>

Avoid broad fallback objects when ordinary programming errors must remain visible. Inside descriptors, raise AttributeError only when the managed attribute is genuinely unavailable; use another exception for corrupt state.

Experiment 8: __getattribute__ sees everything

Unlike __getattr__, __getattribute__ intercepts every ordinary read. Predict what happens if its implementation uses self.__dict__ directly:

Pyodide / WebAssembly
class Audit:
    def __init__(self, value):
        self.value = value

    def __getattribute__(self, name):
        print("[event] attribute read:", name)
        return object.__getattribute__(self, name)


audit = Audit(7)
print("[result] audit value:", audit.value)

This safely prints the audit line and 7 because it delegates to the base implementation. Replacing the return line with return self.__dict__[name] would need to look up self.__dict__, re-enter __getattribute__, and recurse until RecursionError.

Delegation also preserves descriptors, MRO lookup, and normal semantics. An override that reads only __dict__ silently breaks methods, properties, slots, and inherited attributes. Use object.__getattribute__(self, name) for ordinary instances, usually after handling only the narrow special cases you need.

Special method lookup adds another boundary. Operations such as len(obj) generally look for __len__ on the type, not by performing the equivalent of obj.__len__ through arbitrary instance hooks. Do not build proxies on the assumption that intercepting normal dotted access intercepts Python's operator protocols.

What CPython 3.14 does

Disassembling a simple read in CPython 3.14 shows LOAD_ATTR:

import dis


def read_status(job):
    return job.status


for instruction in dis.get_instructions(read_status):
    print(instruction.opname, instruction.argrepr)

On CPython 3.14, the meaningful operations include a local load, LOAD_ATTR status, and a return. After code runs, CPython may specialize attribute operations and populate inline caches. Those optimizations can remember useful type and namespace facts while checking that assumptions remain valid.

CPython detail. Opcode names, cache layouts, specialization, and C routines such as PyObject_GenericGetAttr are not the Python data model. The dis documentation explicitly says bytecode may change across releases and implementations. Use disassembly to explain a measured CPython version, never as an application contract.

The language contract is the observable result and ordering of hooks. A conforming implementation may reach it with different storage, no bytecode, or a compiler that removes repeated work.

Practical decisions

  • Use a plain attribute when storage and access need no mediation.
  • Use property when one class needs validation, computation, or compatibility behind attribute syntax.
  • Write a reusable descriptor when the same field behavior genuinely appears across attributes or classes.
  • Choose a data descriptor when instance shadowing must not bypass the behavior.
  • Choose a non-data descriptor when intentional shadowing or instance caching is part of the design.
  • Prefer __getattr__ over __getattribute__ for missing-name delegation; it disturbs less of the object model.
  • Keep descriptor getters unsurprising. Hidden I/O on innocent-looking attribute access makes latency and failure harder to reason about.
  • For proxies, explicitly decide which special methods to forward; normal attribute hooks are not universal interception.

Framework descriptors should document storage location, class-level behavior, mutability, exceptions, and whether reads cache. Users otherwise cannot tell whether obj.field is cheap state access, a database query, or a validation boundary.

Exercises: test the ladder

  1. Add a same-named instance entry beneath a read-only property. Predict the result, then explain why the property is still a data descriptor.
  2. Modify Label to cache its result in obj.__dict__. Count descriptor calls across three reads.
  3. Attach a descriptor to a class after class creation. Demonstrate what __set_name__ did not do automatically.
  4. Create a diamond inheritance hierarchy and use __mro__ plus vars to predict which method binds.
  5. Write a proxy with __getattr__, then test len(proxy). Add only the special method needed to make it work.
  6. Make a property accidentally raise AttributeError and observe a __getattr__ fallback. Change the internal failure to a more accurate exception.

Keep this model

The dot does not mean "read this object's dictionary." It asks the type's attribute machinery to resolve a name. For ordinary instance lookup, data descriptors outrank instance state; instance state outranks non-data descriptors and plain class attributes; class search follows the MRO; and __getattr__ handles unresolved lookups. Functions bind as methods because they are non-data descriptors.

When an attribute surprises you, ask where the raw class value lives, whether its type implements the descriptor protocol, whether the instance shadows it, and whether a fallback swallowed AttributeError. Those questions usually locate the behavior without relying on CPython folklore.

Primary sources