Why can you set `f.calls = 0` on a Python function but not on an `int`?
answer
- it is only attribute assignment
- some objects have nowhere to store it
- look for a per-object dictionary
- vars() on a function shows it
- builtins have a fixed C layout
basics
~20 sFunction objects carry an ordinary instance dict, so arbitrary attributes stick to them just as they do on a normal instance. int and most builtin types have a fixed C-level layout with no dict, so the assignment raises AttributeError.
solid answer
~40 sAttribute assignment needs somewhere to store the value. A Python function object has a writable per-object dictionary — `f.__dict__`, also visible as `vars(f)` — created lazily, so `f.calls = 0` simply adds an entry. An `int` has no `__dict__` at all: builtin types are defined in C with a fixed layout, and the error even says "no `__dict__` for setting new attributes". The same applies to C builtins: `hasattr(len, '__dict__')` is False. Note that `__name__`, `__qualname__` and `__doc__` are slots on the function type, so they do **not** appear in `vars(f)` — only what you attached does. Attaching static metadata such as a tag or a registration key is idiomatic; using an attribute as mutable runtime state is module-level global state under another name, with no thread-safety and no visibility to type checkers.
code
python · 9 linesdef rebuild_index(batch):
rebuild_index.calls += 1
return len(batch)
rebuild_index.calls = 0
rebuild_index(["a", "b"])
rebuild_index(["c"])
print(rebuild_index.calls, vars(rebuild_index))
print(hasattr(len, "__dict__"))go deeper
Recall that setting an attribute on a function works and setting one on a number does not, and that the difference is whether the object has a place to store attributes at all.
Explain the per-object dict, that vars() shows only what you attached, and why builtin types with a fixed C layout raise AttributeError instead.
Judge when the trick is worth it: static metadata read after import is fine, mutable counters are shared global state with no atomicity and no test isolation.
Own the convention. Decide whether metadata on callables is a sanctioned pattern in your codebase or an unreviewable side channel that static tooling and new readers cannot follow.
### Attribute assignment needs somewhere to put the attribute `f.calls = 0` is the same statement as `obj.attr = value` anywhere else in Python: it asks the object to store a value under a name. Whether it works depends entirely on whether that object *has a place to store it*. A Python function object has an ordinary **instance dictionary** — the same mechanism that lets an instance of a normal class hold arbitrary attributes. You can see it: `f.__dict__` (equivalently `vars(f)`) is an empty dict for a fresh function and gains `{'calls': 0}` after the assignment. The dict is created lazily, so an untouched function costs nothing extra. An `int` has no such dictionary. Builtin types like `int`, `str`, `tuple` and `float` are defined in C with a fixed memory layout and no `__dict__` slot, for the same reason a class using `__slots__` has none: a per-object dict on every small integer would be enormous waste. The interpreter says so plainly: ``` AttributeError: 'int' object has no attribute 'calls' and no __dict__ for setting new attributes ``` The same is true of **builtin functions**: `hasattr(len, '__dict__')` is `False`, because `len` is a `builtin_function_or_method`, not a Python function object. Two things that both look like "a function" differ here. ### What is, and is not, in `__dict__` Only what you attached. `__name__`, `__qualname__`, `__doc__` and `__module__` are **slots on the function type**, not entries in the per-object dict — `vars(f)` does not show them. That surprises people who expect a "copy everything in `__dict__`" loop to carry the name across; it does not. ### Legitimate uses - **Marking.** Tag a function so a later scan can find it: `handler.mode = 'delta'`, then a registry builder picks out the tagged functions from a module. - **A cached resource.** Compile something expensive once and hang it on the function so the next call reuses it, without inventing a module-level global whose relationship to the function is invisible. - **A test seam.** Expose a knob that tests flip, kept next to the code it affects. The appeal in every case is *locality*: the state lives on the object that uses it instead of in a module-level variable ten lines away. ### The caveats a senior answer includes 1. **It is global mutable state wearing a different hat.** Every caller, every thread and every test in the process shares one function object and therefore one attribute. Test isolation goes with it: one test that increments a counter changes what the next test sees. 2. **The increment is not atomic.** `f.calls += 1` is a read, an add and a store. Two threads can interleave and lose an update; on a free-threaded build there is not even a coarse interpreter lock making the window narrow. If a count matters, use a real counter with a lock, or the metrics layer you already have. 3. **It dies when the `def` runs again.** Re-executing the definition — a module reload, or a `def` nested inside a function that is called repeatedly — builds a **new** function object with an empty `__dict__`. Anything attached to the previous object is simply gone. 4. **Static tooling cannot see it.** A type checker knows the declared type of a function; it does not know you invented `f.calls`, so it will flag the access or silently give up. Nothing tells a reader the attribute exists except the line that created it. 5. **Wrapping loses it.** If the function is later wrapped, callers reach the wrapper object, whose own `__dict__` is empty — a problem the decorator layer has to solve deliberately. ### How to decide Attaching an attribute is a fine, idiomatic way to carry *static metadata about the function itself* — a tag, a version marker, a registration key — because that data is written once at import time and only read afterwards. It is a poor way to carry *mutable runtime state*, because you have chosen a shared global with none of the guardrails a real one would get. When mutable per-call or per-worker state is what you need, the honest alternatives are an explicit object holding the state and a method that uses it, a closure over a local, or a purpose-built counter from the library that already handles concurrency for you. Reach for a function attribute when it makes the code *more* obvious, never when it merely makes it shorter.
- Does a builtin function such as `len` accept attributes too?No. `len` is a `builtin_function_or_method` implemented in C, and `hasattr(len, '__dict__')` is False, so assignment raises AttributeError just as it does on an `int`. Instances of a normal Python class do get a `__dict__` unless the class declares `__slots__`, which is the same mechanism the C types use to avoid paying for one per object.
- What goes wrong if you use a function attribute as a call counter?It is module-level mutable state wearing a different hat: every caller, thread and test in the process shares the one function object. The `+=` is a read-modify-write and is not atomic, so concurrent updates can be lost — and a free-threaded build removes even the coarse serialisation. It also resets whenever the `def` executes again, and no type checker knows the attribute exists. For a count that matters, use an explicit counter object.
- How do you inspect what has been attached to a function object?`vars(f)` — the same dict as `f.__dict__` — returns exactly what was attached, and an empty dict when nothing was. `dir(f)` is different: it mixes in everything the function type provides. A useful detail is that `__name__`, `__qualname__` and `__doc__` are slots on the type rather than entries in the per-object dict, so a loop that copies `__dict__` does not carry the name along with it.
saying these in an interview costs you the question
- Says every Python object accepts arbitrary attributes
- Thinks __name__ and __doc__ live inside the function's __dict__
- Treats a function attribute as thread-safe shared state
- Expects an attached attribute to survive re-executing the def
- Believes a static type checker will see the attached attribute
- Assumes builtin functions such as len accept attributes