Bound and Unbound Methods
When you write
obj.method(), Python does not store a “method” anywhere — it manufactures one on the spot. The reason is that a plain function object is also a descriptor: it implements__get__, so the attribute-lookup machinery, when it finds a function in a class and you reach it through an instance, calls the function’s__get__(instance, cls)and gets back a bound method (types.MethodType,PyMethodObjectin C) that captures__self__(the instance) and__func__(the original function). Calling that bound method inserts__self__as the first argument, which is whereselfcomes from — the data model says it precisely: “whenCis a class which contains a definition for a functionf(), andxis an instance ofC, callingx.f(1)is equivalent to callingC.f(x, 1)” (per the data model reference). There is no magic in the function body;selfis just the first positional parameter, and the binding machinery supplies it. The historical “unbound method” type was removed in Python 3 —Class.methodis now simply the function itself.
This note explains the binding mechanism — the descriptor protocol applied to functions, how staticmethod/classmethod change it, and the interpreter optimization that usually avoids building a bound-method object at all. It assumes the function-object layer and the general descriptor protocol as background and cross-links rather than re-deriving them.
Mental Model: A Function Is a Descriptor
The key realization is that methods are not a special language construct — they fall out of two ordinary mechanisms composed together: functions are descriptors, and attribute lookup invokes descriptors. A class body is just a namespace; def method(self): ... inside it stores an ordinary function object in the class’s __dict__. What makes obj.method behave like a method is that the function type defines tp_descr_get (the C slot behind __get__). When attribute lookup finds the function in the type and the access came through an instance, it calls that __get__, which returns a bound method.
graph TD ACC["obj.method (attribute access)"] --> LOOKUP["type(obj).__mro__ search<br/>finds function in class __dict__"] LOOKUP --> ISDESC{"is it a<br/>descriptor?<br/>(has __get__)"} ISDESC -->|"plain function,<br/>via instance"| BIND["func.__get__(obj, type)<br/>→ PyMethod_New(func, obj)"] ISDESC -->|"plain function,<br/>via class"| RAW["returns the function<br/>unchanged (no self)"] ISDESC -->|"staticmethod"| SM["sm.__get__ → wrapped<br/>function, unbound"] ISDESC -->|"classmethod"| CM["cm.__get__ → PyMethod_New(func, cls)<br/>(__self__ = the class)"] BIND --> BM["bound method<br/>__self__ = obj<br/>__func__ = func"] BM --> CALL["bm(1) → func(obj, 1)<br/>(self prepended)"]
Figure: The single decision point is “is the looked-up class attribute a descriptor, and how was it reached?” A plain function reached via an instance binds to a bound method; reached via the class it stays a bare function. staticmethod and classmethod are different descriptors that bind differently. The insight: self injection is not done by the function — it is done by the bound-method wrapper that the descriptor produced, by prepending __self__ to the call.
The Function Descriptor: func_descr_get
The whole binding mechanism for a normal method is four lines of C, verified against Objects/funcobject.c at the v3.14.5 tag:
static PyObject *
func_descr_get(PyObject *func, PyObject *obj, PyObject *type)
{
if (obj == Py_None || obj == NULL) { // (1)
return Py_NewRef(func);
}
return PyMethod_New(func, obj); // (2)
}This is the function type’s tp_descr_get slot — its __get__. (1) When obj is NULL (the attribute was reached through the class, e.g. C.method) or None, it returns the function unchanged: no binding happens, you get back the plain function object. This is why C.method in Python 3 is just a function — and why C.method(some_instance, arg) works: you pass self explicitly. (2) When obj is a real instance (the attribute was reached through x.method), it calls PyMethod_New(func, obj), which builds the bound-method object pairing the function with that instance.
PyMethod_New itself, from Objects/classobject.c:
PyObject *
PyMethod_New(PyObject *func, PyObject *self)
{
if (self == NULL) { PyErr_BadInternalCall(); return NULL; }
PyMethodObject *im = _Py_FREELIST_POP(PyMethodObject, pymethodobjects); // (1)
if (im == NULL) {
im = PyObject_GC_New(PyMethodObject, &PyMethod_Type);
if (im == NULL) return NULL;
}
im->im_weakreflist = NULL;
im->im_func = Py_NewRef(func); // (2) __func__
im->im_self = Py_NewRef(self); // (3) __self__
im->vectorcall = method_vectorcall;
_PyObject_GC_TRACK(im);
return (PyObject *)im;
}(1) It tries a free list first — a small per-interpreter cache of recycled PyMethodObject structs — because bound methods are created and discarded constantly, so reusing freed ones avoids hammering the allocator. (2) and (3) It stores the function as im_func (exposed as __func__) and the instance as im_self (exposed as __self__). A PyMethodObject therefore holds exactly two object references plus a weakref list and a vectorcall pointer — it is a very thin pairing object. The Python data model confirms the attribute names: “method.__self__ refers to the class instance object to which the method is bound” and “method.__func__ refers to the original function object” (data model).
How self Gets Prepended: method_vectorcall
Calling the bound method is where self actually enters the argument list. The method type’s call path, from Objects/classobject.c:
static PyObject *
method_vectorcall(PyObject *method, PyObject *const *args,
size_t nargsf, PyObject *kwnames)
{
PyThreadState *tstate = _PyThreadState_GET();
PyObject *self = PyMethod_GET_SELF(method); // im_self
PyObject *func = PyMethod_GET_FUNCTION(method); // im_func
return _PyObject_VectorcallPrepend(tstate, func, self, args, nargsf, kwnames);
}_PyObject_VectorcallPrepend invokes the underlying function with self inserted as the first positional argument, ahead of the caller-supplied args. That is the entire trick: bm(1) becomes func(self, 1). The function body sees self bound to its first parameter like any other positional argument — nothing in the function knows or cares that it was reached through a method. This is also why the parameter is named self only by convention; the binding machinery prepends __self__ positionally regardless of what the first parameter is called.
Because a PyMethodObject is itself a callable object, method_getattro (its attribute access) delegates unknown attributes to the wrapped function — bound.__doc__, bound.__name__, and any custom function attributes are read straight off im_func, which is why a bound method “looks like” its underlying function.
Class.meth vs instance.meth, and the Death of Unbound Methods
In Python 2, accessing Class.meth produced an unbound method — a wrapper that refused to be called without an instance of the right type as its first argument. Python 3 deleted that concept. As the official changelog states: “The concept of ‘unbound methods’ has been removed from the language. When referencing a method as a class attribute, you now get a plain function object” (per What’s New in Python 3.0). Accessing a function through the class now returns the plain function, with no type checking on the first argument. This is exactly branch (1) of func_descr_get above: obj == NULL → return the function as-is.
The practical consequences:
C.methodisC.__dict__['method'](a function);C.method is C.__dict__['method']may beTruefor a plain function.x.methodis a freshly created bound method each time, sox.method is x.methodisFalseandx.method == x.methodisTrue(methods compare equal when__self__and__func__match).C.method(x, 1)works and is the canonical way to call a base-class method explicitly (super()andBase.method(self, ...)rely on it).- Storing a function on an instance (
x.cb = some_func) does not produce a bound method — binding only happens for functions found in the class, because only then does the type’stp_descr_getget a chance to run. The data model is explicit: “User-defined functions which are attributes of a class instance are not converted to bound methods; this only happens when the function is an attribute of the class.” Sox.cbreturnssome_funcunchanged and you must pass anyselfyourself.
staticmethod and classmethod: Different Descriptors
staticmethod and classmethod are wrapper objects whose entire purpose is to define a different __get__. They are descriptors too, so they participate in the same attribute-lookup machinery — they just bind differently.
classmethod’s __get__, verified in Objects/funcobject.c:
cm_descr_get(PyObject *self, PyObject *obj, PyObject *type)
{
classmethod *cm = (classmethod *)self;
if (type == NULL)
type = (PyObject *)(Py_TYPE(obj));
return PyMethod_New(cm->cm_callable, type); // bind to the CLASS, not the instance
}It calls the same PyMethod_New, but passes type (the class) as the self to bind. So C.cmeth and x.cmeth both produce a bound method whose __self__ is C itself — calling either runs cmeth(C, ...). That is exactly why a classmethod receives the class as its first parameter (cls). When reached through an instance with no explicit type, it falls back to Py_TYPE(obj), the instance’s class.
staticmethod’s __get__ is even simpler:
sm_descr_get(PyObject *self, PyObject *obj, PyObject *type)
{
staticmethod *sm = (staticmethod *)self;
return Py_NewRef(sm->sm_callable); // return the wrapped function, UNBOUND
}It ignores obj and type entirely and returns the wrapped function with no binding. A static method therefore receives neither self nor cls; it is a plain function that merely lives in the class namespace. (Both wrappers also copy __wrapped__/metadata via functools_wraps, and expose the original via __func__.)
A historical wrinkle worth knowing: chaining classmethod over another descriptor — most usefully @classmethod @property to build a class-level computed property — briefly worked. Per the 3.14 classmethod docs, this was “Changed in version 3.9: Class methods can now wrap other descriptors such as property(),” then “Deprecated since version 3.11, removed in version 3.13: Class methods can no longer wrap other descriptors such as property().” So in 3.14 the special chaining is gone: cm_descr_get no longer invokes the wrapped descriptor’s __get__. Writing @classmethod @property def x(cls): ... does not raise, but it does not produce the property’s value — on 3.14.5 C.x returns a bound method bound to the class, not the computed value, which is almost never what the author intended. The supported replacement is a plain classmethod (no inner property) or a custom descriptor.
Worked Example: Watching the Binding Happen
class C:
def meth(self, x): # plain function in C.__dict__
return (self, x)
@classmethod
def cmeth(cls, x):
return (cls, x)
@staticmethod
def smeth(x):
return x
x = C()
type(C.__dict__['meth']) # <class 'function'> — stored as a bare function
C.meth # <function C.meth> — class access, NOT bound (branch 1)
x.meth # <bound method C.meth of <C object>>
x.meth is x.meth # False — a fresh PyMethodObject each access
x.meth == x.meth # True — same __self__ and __func__
x.meth.__self__ is x # True
x.meth.__func__ is C.__dict__['meth'] # True
x.meth(1) # (x, 1) — self prepended by the bound method
C.meth(x, 1) # (x, 1) — explicit self, identical result
x.cmeth(1) # (C, 1) — __self__ is the CLASS
C.cmeth(1) # (C, 1) — same: classmethod binds to C either way
x.cmeth.__self__ is C # True
x.smeth(1) # 1 — no self, no cls
type(x.smeth) # <class 'function'> — staticmethod returns it unbound
# Binding requires the function to live on the CLASS:
x.cb = lambda v: v
x.cb(5) # 5 — NOT bound; lambda is an instance attributeReading this against the mechanism: C.meth hits branch (1) of func_descr_get (obj == NULL) and returns the raw function. x.meth hits branch (2) → PyMethod_New(meth, x), a new object every time (hence is is False). cmeth always reports __self__ is C because cm_descr_get binds the class; smeth is returned unbound by sm_descr_get, so type(x.smeth) is still function. And x.cb proves the instance-attribute rule: the lambda was stored on the instance, never on the class, so no tp_descr_get ran and no binding occurred.
The Optimization: No Bound Method on the Hot Path
Manufacturing a fresh PyMethodObject for every obj.meth() call would be wasteful — allocate, set two fields, call, immediately discard. CPython avoids it with a two-instruction dance that keeps the receiver and the function separate on the value stack instead of fusing them into a method object.
Historically (≤3.11) this used a dedicated LOAD_METHOD opcode. In 3.12 LOAD_METHOD was folded into LOAD_ATTR (per What’s New in 3.12); a low bit of the opcode’s oparg now signals “this is a method call.” Verified in Python/bytecodes.c at v3.14.5, when oparg & 1 is set, _LOAD_ATTR produces two stack outputs — attr and self_or_null:
op(_LOAD_ATTR_METHOD_WITH_VALUES, (descr/4, owner -- attr, self)) {
assert(oparg & 1);
attr = PyStackRef_FromPyObjectNew(descr); // the unbound function from the class
self = owner; // the instance, kept separate
}Instead of building a bound method, it pushes the raw function (attr) and the instance (self) as two adjacent stack entries. The matching CALL then sees a non-null self_or_null and treats it as an extra leading argument — calling func(self, ...args) directly, with zero PyMethodObject allocation. The generic _DO_CALL handler shows the arithmetic: if (!PyStackRef_IsNull(self_or_null)) { arguments--; total_args++; } — it simply widens the argument window by one to include the already-on-stack receiver.
This fast path is specialized further by the adaptive specializer. The LOAD_ATTR family has method variants (LOAD_ATTR_METHOD_WITH_VALUES, LOAD_ATTR_METHOD_NO_DICT, LOAD_ATTR_METHOD_LAZY_DICT) that, after guarding the type version, skip the descriptor call entirely and push the cached function + instance. The CALL family then has bound-method specializations for the cases where a method object did materialize (e.g. a method stored in a variable and called later): CALL_BOUND_METHOD_EXACT_ARGS and CALL_BOUND_METHOD_GENERAL. Their guards, from bytecodes.c:
op(_CHECK_CALL_BOUND_METHOD_EXACT_ARGS, (callable, null, unused[oparg] -- ...)) {
EXIT_IF(!PyStackRef_IsNull(null));
EXIT_IF(Py_TYPE(PyStackRef_AsPyObjectBorrow(callable)) != &PyMethod_Type);
}
op(_INIT_CALL_BOUND_METHOD_EXACT_ARGS, (callable, self_or_null, unused[oparg] -- ...)) {
self_or_null = PyStackRef_FromPyObjectNew(((PyMethodObject *)callable_o)->im_self); // (1)
callable = PyStackRef_FromPyObjectNew(((PyMethodObject *)callable_o)->im_func); // (2)
}When the callable on the stack is a PyMethodObject, this specialization unpacks it in place: (1) it pulls im_self out to become the prepended self_or_null, and (2) replaces the method on the stack with its underlying im_func. From there the call proceeds exactly as the un-fused method-call path. The method object is split back into function + self rather than being invoked through method_vectorcall, eliminating one indirection on the hot path. Crucially, CALL_BOUND_METHOD_* also guards on the underlying function’s func_version via _CHECK_METHOD_VERSION — it reads ((PyMethodObject *)callable)->im_func and compares its func_version to the cached one, so the same version invalidation that protects direct function calls also protects bound-method calls; mutating the underlying function de-optimizes the method call site too.
Production Notes
The freshly-allocated-bound-method behavior has real consequences. Registering obj.handler as a callback (event listeners, signal handlers) stores a PyMethodObject that holds a strong reference to obj via im_self — a classic source of accidental object retention. The reverse problem also bites: because “a bound method is ephemeral… a standard weak reference cannot keep hold of it,” weakref.ref(c.method) is dead almost immediately, since the bound method object is collected right after creation even while c lives (per the weakref docs). The standard library answer is weakref.WeakMethod, which “has special code to recreate the bound method until either the object or the original function dies” — it weakly references the underlying object and function and re-binds (re-runs the descriptor) on each call, exactly because a normal bound method would otherwise pin __self__ or vanish. Conversely, comparing bound methods for de-registration must use == (matching __self__/__func__), never is, since the registered method object and a freshly-accessed one are never identical.
Performance-wise, the LOAD_ATTR (oparg & 1) + CALL pair means the common obj.meth(args) call site allocates no method object at all once specialized — the receiver and function ride the value stack separately and the call widens its argument window by one. The only time a PyMethodObject is actually built is when a method is detached from its call — bound and stored (cb = obj.meth), passed as an argument, or accessed without immediately calling. The free list in PyMethod_New exists to make even those cases cheap, since detached-then-discarded methods are common in callback-heavy code. The takeaway for hot loops: obj.meth(x) is essentially free, but [obj.meth for obj in objs] materializes one bound method per element.
Common Misunderstandings
“x.meth is x.meth should be true.” No — each access runs func_descr_get → PyMethod_New, producing a new object. Identity differs; equality (same __self__ and __func__) holds. Caching m = x.meth once is the way to get a stable reference.
“self is a keyword / injected by the interpreter into the function.” No — self is an ordinary first parameter; the bound method (or the self_or_null stack slot) prepends __self__ to the argument list. Rename it to this and everything still works; only convention says self.
“staticmethod makes a function ‘not a method’.” It makes the descriptor return the function unbound. The function is still an attribute of the class; it just receives no implicit first argument. Inside the class it resolves to the plain wrapped function via sm_descr_get.
A related point: a bare staticmethod object is itself directly callable, independent of any class. The 3.14 staticmethod docs state it plainly — “the static method descriptor is also callable, so it can be used in the class definition (such as f())” — and it is verified on 3.14.5: staticmethod(len)([1, 2]) evaluates to 2, the descriptor forwarding the call straight to its wrapped callable without any binding. This is why a @staticmethod-decorated function can be invoked by name inside the class body during class creation, before the class object even exists.
Resolved (2026-06-01)
Direct callability of
staticmethodobjects is confirmed against the 3.14 docs and a live 3.14.5 run. The often-cited “since 3.10” attribution was not verifiable here: the only “Changed in version 3.10” note in the 3.14staticmethoddocs concerns static methods inheriting method attributes (__module__,__name__, …), not callability. The exact release that made barestaticmethodobjects callable is left unattributed rather than asserted from memory.
“Assigning a function to an instance gives a method.” No — binding requires the function to be found in the class, so only the class’s tp_descr_get runs. Instance-attribute functions are returned raw.
See Also
- Function Objects and Code Objects — the function being bound; the source of
__func__andfunc_version - Descriptors — the general
__get__/__set__protocol that makes binding possible - Attribute Lookup Mechanics — how
obj.methodreaches the class and invokes__get__ - Adaptive Specialization Families —
LOAD_ATTR_METHOD_*andCALL_BOUND_METHOD_*specializations - Stack Frames and the Frame Stack — the frame a method call ultimately pushes
- Python Internals MOC — §12 Functions, Scopes, and Namespaces