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, PyMethodObject in C) that captures __self__ (the instance) and __func__ (the original function). Calling that bound method inserts __self__ as the first argument, which is where self comes from — the data model says it precisely: “when C is a class which contains a definition for a function f(), and x is an instance of C, calling x.f(1) is equivalent to calling C.f(x, 1)” (per the data model reference). There is no magic in the function body; self is just the first positional parameter, and the binding machinery supplies it. The historical “unbound method” type was removed in Python 3 — Class.method is 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.method is C.__dict__['method'] (a function); C.method is C.__dict__['method'] may be True for a plain function. x.method is a freshly created bound method each time, so x.method is x.method is False and x.method == x.method is True (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() and Base.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’s tp_descr_get get 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.” So x.cb returns some_func unchanged and you must pass any self yourself.

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 attribute

Reading 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_getPyMethod_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 staticmethod objects 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.14 staticmethod docs concerns static methods inheriting method attributes (__module__, __name__, …), not callability. The exact release that made bare staticmethod objects 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