Python’s object model is elegant but subtly powerful.
One of its most misunderstood behaviors is method binding—specifically:
- Why a function defined inside a class behaves differently when accessed through a class vs an instance.
- Why no error occurs when calling
ClassName.method(arg1, arg2)even though the method “expects” aself. - Why
instance.method(arg1, arg2)may raise aTypeError. - What roles
@staticmethodand @classmethod actually play. - How the descriptor protocol (
__get__) governs all of this.
This document provides a clear and faithful explanation of the mechanism behind method binding in Python.
A function defined inside a class is not automatically a method; it becomes one only when accessed through an instance.
class Sample:
def action(x, y):
return x + yInside the class dictionary, the entry:
Sample.__dict__['action']is a plain function object, not a method.
Python transforms this into a bound method only when accessed through an instance.
This is because functions implement:
function.__get__(instance, owner)This descriptor method is the core of method binding.
Accessing:
Sample.actioninvokes:
function.__get__(instance=None, owner=Sample)
Since instance=None, the descriptor returns the raw function unchanged.
- No
selfis injected - No binding occurs
- You receive the plain function
Thus:
Sample.action(10, 20)executes as:
action(10, 20)with:
x = 10
y = 20
Accessing:
obj = Sample()
obj.actioninvokes:
function.__get__(instance=obj, owner=Sample)
This creates a bound method where the instance is automatically injected:
x = obj
y = 10
So:
obj.action(10, 20)internally becomes:
Sample.action(obj, 10, 20)If the function does not accept this extra argument, Python raises:
TypeError: action() takes 2 positional arguments but 3 were given
class Utility:
@staticmethod
def add(a, b):
return a + bstaticmethod suppresses binding:
- No instance injected
- No class injected
So these behave identically:
Utility.add(5, 6)
Utility().add(5, 6)class Counter:
@classmethod
def increment(cls):
return clsThe class method descriptor always injects the class:
- Class access →
cls = Counter - Instance access →
cls = Counter
Thus, both forms call:
increment(Counter)Accessing:
Sample.__dict__['action']bypasses the descriptor protocol entirely.
This returns the raw function object, unchanged.
Because class attribute access does not bind, Python never injects self.
Thus:
Sample.action(10, 20)is just a plain function call, not a method call.
The examples/ directory demonstrates:
- Class vs instance access
- Signature mismatch
- Static methods
- Class methods
- Raw descriptor access
The tests/ directory validates:
- Class access → no binding
- Instance access → binding
- Static method behavior
- Class method behavior
- Raw access via
__dict__
Run using:
pytest
Two diagrams in the docs/ directory:
python-method-binding-diagram.svgpython-method-binding-method-types.svg
Python’s method binding rules come entirely from the descriptor protocol.
| Access Type | Descriptor Behavior | First Arg Injected |
|---|---|---|
| Class access | No binding | None |
| Instance access | Bound method | Instance (self) |
| @staticmethod | No binding | None |
| @classmethod | Bound method | Class (cls) |
Understanding this reveals the elegant consistency of Python’s object model.