skip to content

Why does a module-level `__getattr__` re-run its work on every attribute access?

level: seniorimportance: nice to knowfreq 15%

answer

  1. Nothing memoizes what the hook returns
  2. A miss handler runs on every miss
  3. sys.modules covers the module, not the hook
  4. Bind the name to skip later calls
  5. globals()[name] = value before returning

basics

~20 s

Because the hook is consulted on every failed lookup and nothing caches what it returns. sys.modules stops an imported submodule executing twice, but the hook's body repeats until it binds the value into module globals.

solid answer

~50 s

The hook is a miss handler, so it runs whenever the name is absent from the module's `__dict__` - and returning a value does not put it there. `sys.modules` protects the *imported submodule* from executing twice, but any work in the hook body itself, such as building an object or registering a rule, repeats on every access. The fix is one line: bind the value into the module globals before returning it, after which the dict lookup succeeds and the hook is skipped. Two traps: a single `from pkg import thing` against a package calls the hook twice (the fromlist handler probes, then the bytecode fetches), so a non-idempotent body duplicates immediately; and nothing serializes the hook, because the import lock covers module execution, not your function - two threads can both build, which matters more on 3.14's officially supported free-threaded build.

code

python · 23 lines
python
import sys
import types

mod = types.ModuleType("differ")
registrations = []


def _module_getattr(name):
    if name == "engine":
        registrations.append(name)       # a side effect that must happen once
        engine = {"crew": 4}
        mod.__dict__[name] = engine      # bind it: later accesses skip the hook
        return engine
    raise AttributeError(f"module {mod.__name__!r} has no attribute {name!r}")


mod.__getattr__ = _module_getattr
sys.modules["differ"] = mod

import differ

differ.engine, differ.engine, differ.engine
print(registrations)   # ['engine'] - without the binding line, three entries

go deeper

for a junior

Take away the core fact: a module's __getattr__ is called every time a name is missing, and returning a value does not store it anywhere. Repetition is the default, not the exception.

for a middle

Be able to write the one-line fix and say why it works: binding into globals() puts the name where attribute lookup checks before the hook, so the hook is skipped from then on.

for a senior

Show the diagnosis: a duplicated side effect traced back to a lazy attribute, the doubled call from a single from-import, and the decision to move registration into the imported module where import runs it once.

for a principal

Own the guidance you would give a team: hooks stay pure lookups, side effects live in module top-level code, and any shared-state build gets a lock or idempotence rather than relying on interpreter accidents.

### The mechanism A module-level `__getattr__` is consulted on *every* failed attribute lookup. Returning a value does not bind it: the module's `__dict__` is unchanged, so the next access misses again and the hook runs again. There is no memoization anywhere in the path, and that surprises people because a neighbouring mechanism - `sys.modules` - looks like it should cover the case. It does not. `sys.modules` guarantees that an imported module's *top-level code* runs once. It says nothing about the body of your hook. So a hook written as "import the heavy submodule and build the object" is genuinely cheap on the import, and genuinely repeated afterwards: the `import_module` call is a dict hit after the first time, but the building, registering, logging and validating around it are not. ### What that costs, concretely Take a flight-schedule differ maintained by a four-person team. The package defers its comparison engine: ```python def __getattr__(name): if name == "engine": from .compare import Engine # cheap after the first time engine = Engine() _RULES.append(engine.default_rule) # NOT cheap after the first time return engine raise AttributeError(f"module {__name__!r} has no attribute {name!r}") ``` The registration is a duplicated side effect waiting to happen. Every `schedules.engine` in the codebase appends the default rule again, so the differ applies it four times and the report lists each changed leg four times over. Worse, a single `from schedules import engine` against a package calls the hook **twice** for one statement - the from-import machinery probes the attribute before the bytecode fetches it - so the duplication starts before anyone has written a second access. The bug is not in the diffing logic anyone would look at first; it is in a hook nobody thinks of as code that runs repeatedly. ### The fix: bind the name ```python def __getattr__(name): if name == "engine": from .compare import Engine engine = Engine() _RULES.append(engine.default_rule) globals()[name] = engine # the dict lookup now wins forever return engine raise AttributeError(f"module {__name__!r} has no attribute {name!r}") ``` Binding into `globals()` - equivalently the module's `__dict__` - puts the name where attribute lookup looks *first*, so the hook is never consulted for it again. This is the same rule from the other direction: the hook skips names already in globals, so caching is simply putting the name there yourself. It also fixes identity: without it, each access hands out a different object, so `a is b` fails and anything keyed on the object misbehaves. ### The parts binding does not fix **Threads.** Nothing serializes the hook body. The import system's per-module lock covers executing a module, not calling your function, so two threads that miss simultaneously can both build the object and both run the side effect; the second binding simply overwrites the first, leaving two live objects and one of them orphaned. If the body must run once, make it idempotent or guard it with a `threading.Lock` held around the build-and-bind. This has always been true; the incidental serialization people relied on came from the GIL happening to make short bodies look atomic, and Python 3.14's free-threaded build - officially supported as of PEP 779 - removes that accident. **Re-arming.** `del pkg.engine` removes the binding and the next access runs the hook again, side effect and all. That is occasionally useful and more often a surprise in test code that tries to reset module state between cases. **Deliberate non-caching.** Sometimes repetition is the point. A deprecation redirect usually should *not* bind, because the warning at each call site is the entire value of the shim; the warnings filter already collapses duplicates per source line. The decision is: bind when the hook does work, do not bind when the hook is a message. ### How to keep it safe The durable discipline is to keep the hook body a *pure lookup that binds*: resolve or construct the value, bind it, return it, and put nothing in it that would be wrong to run twice. Registration, mutation of shared state, opening handles and logging all belong in the imported module's own top-level code, where `sys.modules` genuinely does guarantee once-per-process execution. If you find yourself reasoning about how many times the hook fires, that reasoning is the smell - move the side effect somewhere the interpreter already promises to run once.

  • How many times does one `from pkg import thing` call a package's module `__getattr__`?
    Twice. The import machinery handling the fromlist probes the module for `thing` to decide whether it needs importing as a submodule, and then the bytecode that binds the name performs its own attribute lookup. Against a plain module rather than a package it is called once for `thing`, plus an earlier probe for `__path__`. Either way, a non-idempotent hook body is wrong from the first statement.
  • Does binding the value into globals make the hook thread-safe?
    No. Two threads can miss at the same moment, both run the body and both bind; the last write wins and the other object is orphaned, along with whatever side effects it performed. The import lock protects module execution, not your function. Make the body idempotent, or hold a `threading.Lock` across the build-and-bind. It matters more on a free-threaded build, where short bodies no longer look atomic.
  • When should a module `__getattr__` deliberately not cache its result?
    When the repetition is the feature. A deprecation redirect wants the warning to fire at each distinct call site, and the warnings filter already collapses duplicates per location, so binding would silence it after the first hit. Similarly, a value that must reflect live state should not be frozen into the module globals. Bind when the hook does work; skip the binding when the hook is a message.

It is a doorbell, not a doorstop: it rings every time nobody is at the front desk, and only writing the name into the desk register stops the ringing.

saying these in an interview costs you the question

  • Assumes the interpreter caches whatever the hook returns
  • Thinks sys.modules protects work done inside the hook
  • Puts non-idempotent registration side effects in the hook body
  • Believes the import lock serializes the hook
  • Returns a fresh object per call, so identity checks fail
  • Assumes one from-import triggers exactly one hook call

context