Functions vs Methods in Python: What Is Actually Happening When You Call Them

Here is a question that trips up even experienced Python developers: what is the actual, mechanical difference between a function and a method? The common answer — "a method is a function that belongs to a class" — is not wrong, but it leaves you confused the moment something unexpected happens. The real answer involves one of Python's most elegant and least understood mechanisms: the descriptor protocol.

Understanding the descriptor protocol does not just clear up the function-vs-method question. It reveals how property, staticmethod, classmethod, and the entire attribute lookup system actually work. This is the kind of knowledge the Python CodeCrack philosophy is built around: not copy-paste definitions, but genuine comprehension of what the interpreter is doing.

The Surface-Level Difference (And Why It Is Incomplete)

At the surface, the distinction seems obvious. A function is defined at module level with def. A method is defined inside a class with def. The method gets self as its first parameter. Done.

# A function
def greet(name: str) -> str:
    return f"Hello, {name}"

# A class with a method
class Greeter:
    def greet(self, name: str) -> str:
        return f"Hello, {name}"

But this framing immediately runs into problems. Consider this:

class Dog:
    def speak(self):
        return "Woof"

print(type(Dog.__dict__['speak']))
# <class 'function'>

print(type(Dog.speak))
# <class 'function'>

d = Dog()
print(type(d.speak))
# <class 'method'>

The same def speak produces a function when accessed through the class dictionary, a function when accessed on the class itself, and a method when accessed on an instance. The object is not inherently "a function" or "a method." It transforms depending on how you access it.

The mechanisms behind those first two function results are different, though the outcome looks identical. Dog.__dict__['speak'] is a raw dictionary lookup — it bypasses the descriptor protocol entirely and returns the raw function object sitting in the class's namespace. Dog.speak, by contrast, does invoke the descriptor protocol: Python calls speak.__get__(None, Dog), which returns the function itself unchanged because the instance argument is None. Both produce a function, but the first skips the protocol and the second invokes it. It is only when accessed through an instance that __get__ receives a real instance and returns a bound method. Understanding why requires going deeper.

Functions Are Objects. All of Them.

In Python, def always creates a function object. Always. It does not matter whether the def appears at module level, inside a class body, inside another function, or inside an if block. The def statement evaluates to a function object and assigns it to the given name in the current namespace.

(The int | float union syntax in type annotations requires Python 3.10 or later. Python 3.9 reached end-of-life on October 31, 2025.)

class Calculator:
    def add(self, a: int | float, b: int | float) -> int | float:
        return a + b

# The class body is a namespace. 'add' is stored as a regular function.
print(Calculator.__dict__['add'])
# <function Calculator.add at 0x...>

print(type(Calculator.__dict__['add']))
# <class 'function'>
Key Insight

When Python executes a class body, it does not create "method objects." It creates function objects and stores them in the class's namespace dictionary. The transformation into a method happens later, at access time, through the descriptor protocol.

The Python Descriptor Guide in the CPython documentation states this plainly: functions stored in class dictionaries get turned into methods when invoked. This is worth sitting with. The method does not exist until you access the function through an instance.

The Descriptor Protocol: Where the Magic Lives

A descriptor is any object that implements __get__, __set__, or __delete__. When such an object lives as a class attribute and is accessed through an instance, Python's attribute lookup machinery calls the descriptor's __get__ method instead of simply returning the object.

Here is the key fact: every Python function implements __get__. This makes every function a non-data descriptor. You can verify this yourself:

def my_function():
    pass

print(hasattr(my_function, '__get__'))
# True

When you access a function through an instance, Python's attribute lookup detects that the function has a __get__ method and calls it. The function's __get__ creates and returns a bound method object, which packages together the original function and the specific instance.

Here is what happens step by step when you write d.speak() for an instance d of class Dog:

  1. Python looks up speak in d.__dict__. Not found.
  2. Python walks the Method Resolution Order (MRO) — type(d).__mro__ — searching each class's __dict__ in turn. It finds speak in Dog.__dict__.
  3. Python checks: does this object have a __get__ method? Yes, functions are descriptors.
  4. Python calls Dog.__dict__['speak'].__get__(d, Dog).
  5. This returns a bound method object where __self__ is d and __func__ is the original function.
  6. Python calls this bound method, which internally calls speak(d), passing d as the first argument.

That is where self comes from. It is not magic. It is not a language keyword with special behavior. It is the result of the descriptor protocol injecting the instance as the first argument.

You can simulate this entire process manually:

class Dog:
    def speak(self):
        return f"Woof from {self}"

d = Dog()

# Normal method call
print(d.speak())
# Woof from <__main__.Dog object at 0x...>

# Manual descriptor invocation - identical result
bound = Dog.__dict__['speak'].__get__(d, Dog)
print(bound())
# Woof from <__main__.Dog object at 0x...>

# Inspecting the bound method
print(bound.__self__)   # The instance: <__main__.Dog object at 0x...>
print(bound.__func__)   # The original function: <function Dog.speak at 0x...>
"Methods only differ from regular functions in that the object instance is prepended to the other arguments. By convention, the instance is called self but could be called this or any other variable name." — Python Descriptor Guide, docs.python.org

Proof: Any Function Can Become a Method

If the descriptor protocol is really just the function-to-method transformation, then you should be able to take any regular function and attach it to a class, and it should behave as a method. And you can.

class Cat:
    def __init__(self, name: str) -> None:
        self.name = name

# A plain function, defined outside any class.
# Because Cat is already defined above, we can use it directly in the annotation.
def purr(self: Cat) -> str:
    return f"{self.name} purrs"

# Attach it to the class
Cat.purr = purr

whiskers = Cat("Whiskers")
print(whiskers.purr())
# Whiskers purrs

print(type(whiskers.purr))
# <class 'method'>

The function purr was never defined inside a class body. But when it became a class attribute and was accessed through an instance, the descriptor protocol kicked in and transformed it into a bound method. What turns a function into a method is not that the function is defined in a class statement's body — it is that the function is an attribute of the class.

This also means you can attach functions to individual instances, but the behavior is different:

whiskers = Cat("Whiskers")

# Attaching to the instance dict bypasses the descriptor protocol
whiskers.hiss = lambda: "Hssss"

print(whiskers.hiss())   # Works, but...
print(type(whiskers.hiss))
# <class 'function'>  -- NOT a method!
Pro Tip

When you assign directly to an instance's __dict__, Python finds the attribute there before checking the class, so the descriptor protocol never triggers. The function remains a plain function and does not receive self automatically.

The Python 2 to Python 3 Transition: Unbound Methods Disappeared

If you have ever read older Python documentation or tutorials, you may have encountered the term "unbound method." In Python 2, accessing a function through the class (not an instance) returned an "unbound method" object rather than the raw function:

# Python 2 behavior (no longer exists)
class Dog:
    def speak(self):
        return "Woof"

Dog.speak        # <unbound method Dog.speak>
type(Dog.speak)  # <type 'instancemethod'>

Python 3 eliminated unbound methods entirely. Accessing a function through the class now returns the plain function object. The rationale was simplification: the unbound method type existed primarily to enforce a type check. In Python 3, Dog.speak is just a function, and you can call Dog.speak(42) without a type error (though 42 probably will not have the attributes you expect).

# Python 3: This works. Whether it's useful is another question.
class Dog:
    def speak(self):
        return f"Woof from {self}"

print(Dog.speak("not a dog"))
# Woof from not a dog

PEP 575, authored by Jeroen Demeyer, later analyzed this landscape in detail and observed that in its proposed hierarchy there would be no difference between functions and unbound methods — confirming that the removal of unbound methods in Python 3 was not a loss of functionality, but a simplification that moved the distinction from a type to an access pattern.

staticmethod, classmethod, and How They Hijack the Protocol

Now that you understand the descriptor protocol, staticmethod and classmethod become trivially understandable. They are just descriptors that override the default __get__ behavior.

staticmethod wraps a function in a descriptor whose __get__ returns the original function unchanged, without binding it to anything:

class MathUtils:
    @staticmethod
    def add(a: int | float, b: int | float) -> int | float:
        return a + b

# Accessing through an instance: still a regular function, no 'self' injected
m = MathUtils()
print(type(m.add))
# <class 'function'>

print(m.add(3, 4))
# 7
Python 3.10 Change

As of Python 3.10, staticmethod objects became directly callable as regular functions. Prior to 3.10, calling the raw staticmethod object directly — for example, from inside a class body before the descriptor machinery runs, or from a dispatch dictionary built within the class definition — raised TypeError: 'staticmethod' object is not callable. Normal attribute access via the class or an instance (MyClass.method(), instance.method()) always worked. Python 3.10 also added method attribute inheritance: staticmethod objects now carry __module__, __name__, __qualname__, __doc__, __annotations__, and a new __wrapped__ attribute forwarded from the wrapped function. See the Python 3.10 changelog for staticmethod.

You can implement a simplified version yourself to see the mechanism (based on the pattern in the Python Descriptor Guide):

class MyStaticMethod:
    def __init__(self, func):
        self.func = func

    def __get__(self, obj, objtype=None):
        return self.func  # Return the raw function, no binding

classmethod wraps a function in a descriptor whose __get__ binds the function to the class rather than the instance:

class Registry:
    _items: list[str] = []  # Class variable shared by all instances — not a per-instance default

    @classmethod
    def register(cls, item: str) -> None:
        cls._items.append(item)

# Whether you call it on the class or an instance, cls is the class
Registry.register("widget")

r = Registry()
r.register("gadget")

print(Registry._items)
# ['widget', 'gadget']

A simplified implementation reveals the mechanism:

class MyClassMethod:
    def __init__(self, func):
        self.func = func

    def __get__(self, obj, objtype=None):
        if objtype is None:
            objtype = type(obj)
        def wrapper(*args, **kwargs):
            return self.func(objtype, *args, **kwargs)
        return wrapper
Production Note

A production implementation of MyClassMethod.__get__ would apply functools.wraps(self.func)(wrapper) before returning, so that __name__, __doc__, __qualname__, and other metadata are forwarded from the original function to the wrapper. The simplified version above omits this for clarity. CPython's actual classmethod also includes a __set__ method (making it a data descriptor internally) and a __delete__ stub — neither is shown here, as they do not affect the core binding behavior this example demonstrates.

This is not abstract theory. It is how Python actually implements these features internally. The official Python documentation on descriptors describes this pattern explicitly.

Python 3.13 Breaking Change: @classmethod + @property

The ability to chain @classmethod with @property (or other descriptors) was formally introduced in Python 3.9, deprecated with a DeprecationWarning in Python 3.11, and fully removed in Python 3.13. The CPython changelog describes the removal bluntly: the core design of this feature was flawed and led to several downstream problems. On Python 3.13+, any code combining these two decorators raises TypeError. If you need a class-level computed attribute, implement a custom descriptor instead — a classproperty class with its own __get__ that binds to the class rather than the instance is the standard mitigation. See the Python 3.13 changelog for details.

property: A Data Descriptor You Use Every Day

While staticmethod is a non-data descriptor (it only implements __get__), classmethod is technically a data descriptor in CPython's implementation — it implements both __get__ and __set__. That said, its __set__ is not user-facing: it exists for internal implementation reasons and does not intercept or validate attribute writes the way property does. For all practical purposes, classmethod behaves like a non-data descriptor. property is the canonical example of a data descriptor: it implements both __get__ and __set__ in a way that actively controls attribute access, which gives it higher priority in the lookup chain than instance dictionaries.

This means property can intercept both reads and writes on an attribute, making it the right tool for validated or computed attributes:

import math

class Circle:
    def __init__(self, radius: float) -> None:
        self._radius = radius

    @property
    def radius(self) -> float:
        return self._radius

    @radius.setter
    def radius(self, value: float) -> None:
        if value < 0:
            raise ValueError(f"Radius cannot be negative: {value}")
        self._radius = value

    @property
    def area(self) -> float:
        return math.pi * self._radius ** 2

c = Circle(5)
print(c.radius)   # 5
print(c.area)     # 78.53981633974483

c.radius = 10
print(c.area)     # 314.1592653589793

c.radius = -1     # Raises: ValueError: Radius cannot be negative: -1
Key Insight

Because property is a data descriptor, it takes precedence over instance __dict__. This is why assigning to a property triggers the setter rather than simply writing to the instance dictionary. A non-data descriptor like a plain function would lose that battle.

A simplified pure-Python equivalent, matching the pattern from the Python Descriptor Guide, illustrates the data descriptor mechanism:

class MyProperty:
    def __init__(self, fget=None, fset=None):
        self.fget = fget
        self.fset = fset

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError("unreadable attribute")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("can't set attribute")
        self.fset(obj, value)

    def setter(self, fset):
        return type(self)(self.fget, fset)

Built-in Functions vs Python Functions vs Methods: A Complete Taxonomy

Python has several distinct callable types, and they behave differently under the hood.

Regular functions are created by def at module level. They are function objects with a __get__ descriptor and live in the module's namespace.

def greet(name):
    return f"Hello, {name}"

print(type(greet))
# <class 'function'>

Built-in functions are implemented in C (like len, print, sorted). They are builtin_function_or_method objects. They also have a __get__ method but behave slightly differently when bound.

print(type(len))
# <class 'builtin_function_or_method'>

Lambda functions are anonymous function objects. Despite the syntax difference, they are the same type as regular functions:

f = lambda x: x + 1
print(type(f))
# <class 'function'>

Bound methods are created on-the-fly when a function is accessed through an instance. They hold a reference to both the function and the instance:

class Dog:
    def speak(self):
        return "Woof"

d = Dog()
m = d.speak
print(type(m))        # <class 'method'>
print(m.__func__)     # <function Dog.speak at 0x...>
print(m.__self__)     # <__main__.Dog object at 0x...>
Watch Out

Bound methods are created fresh each time you access them. d.speak is d.speak evaluates to False — each access through the descriptor protocol creates a new bound method object. This is why caching a method reference before a tight loop can be a useful micro-optimization in performance-critical code.

The PEPs That Shaped This Landscape

PEP 3155 — Qualified name for classes and functions (Antoine Pitrou, Python 3.3, Final) addressed introspection lost when unbound methods were removed. It introduced the __qualname__ attribute, which provides a dotted path from the module level to the object. For methods, this includes the class name: Dog.speak.__qualname__ returns 'Dog.speak'. This restored the ability to identify which class a function was defined in without requiring the old unbound method machinery.

PEP 575 — Unifying function/method classes (Jeroen Demeyer, Withdrawn) proposed reorganizing the class hierarchy for functions and methods, with the stated goal of reducing the difference between built-in functions (implemented in C) and Python functions — specifically, making built-in functions behave more like Python functions without sacrificing performance. Though withdrawn in favor of PEP 580 and eventually vectorcall (PEP 590), PEP 575 produced a thorough analysis of the function/method type landscape in CPython, including the observation that in its proposed hierarchy "there is no difference between functions and unbound methods."

PEP 544 — Protocols: Structural subtyping (Ivan Levkivskyi, Jukka Lehtosalo, and Łukasz Langa, Python 3.8, Final) formalized the concept of "protocols" for static type checking, providing a way to express structural subtyping — what Python programmers have always done informally through duck typing. Rather than requiring explicit inheritance, a class satisfies a protocol simply by implementing the right methods and attributes. This elevated duck typing from an informal convention to a first-class concept in Python's type system, allowing tools like mypy to verify structural compatibility statically without requiring class hierarchies.

PEP 649 — Deferred Evaluation of Annotations Using Descriptors (Larry Hastings, Python 3.14, Final) is, fittingly, a story about descriptors solving a long-standing annotation problem. Python 3.14 shipped on October 7, 2025, and PEP 649 shipped with it. Prior to 3.14, type annotations were evaluated eagerly at definition time, which caused NameError failures with forward references and added runtime overhead even when annotations were never inspected. PEP 649 solved this by making __annotations__ a data descriptor on function, class, and module objects. Instead of storing the annotations dict directly, Python now stores a lightweight __annotate__ callable — a code object compiled from the annotation expressions — that builds the dict on demand and caches the result in __annotations__. The annotations are only evaluated when something actually accesses them, eliminating the need to quote forward references. This is a direct application of the descriptor protocol's lazy-evaluation power. The companion PEP 749, also implemented in Python 3.14, added the annotationlib module and refined corner cases left underspecified by PEP 649. Together they replace the earlier PEP 563 approach of stringizing annotations at compile time, which broke runtime introspection. (As of Python 3.14, from __future__ import annotations still works but is now deprecated and will be removed in a future version.)

Practical Implications You Should Know

Understanding the function/method distinction at this level has real consequences for everyday Python development.

Callbacks and method references. When you pass a bound method as a callback, the instance is carried along:

class Button:
    def __init__(self, label: str) -> None:
        self.label = label

    def on_click(self) -> None:
        print(f"{self.label} clicked")

b = Button("Submit")
callback = b.on_click  # Bound method: carries reference to b

callback()
# Submit clicked

This means the instance will not be garbage collected as long as the bound method reference exists. In GUI applications and event-driven code, this is a common source of memory leaks. The standard mitigation is weakref.WeakMethod (added in Python 3.4), which holds a weak reference to a bound method without preventing the instance from being collected:

import weakref

class Button:
    def __init__(self, label: str) -> None:
        self.label = label

    def on_click(self) -> None:
        print(f"{self.label} clicked")

b = Button("Submit")

# A regular reference keeps 'b' alive indefinitely:
# callback = b.on_click

# A WeakMethod lets 'b' be garbage-collected when nothing else holds it:
callback_ref = weakref.WeakMethod(b.on_click)

# Dereference before calling — returns None if 'b' was collected
callback = callback_ref()
if callback is not None:
    callback()
# Submit clicked

First-class functions and methods. Because methods are just transformed functions, you can pass them around, store them in data structures, and compose them just like any other callable:

class Converter:
    def to_upper(self, text: str) -> str:
        return text.upper()

    def to_lower(self, text: str) -> str:
        return text.lower()

c = Converter()
transforms = [c.to_upper, c.to_lower]

for t in transforms:
    print(t("Hello World"))

# HELLO WORLD
# hello world

Monkey patching. Because the function-to-method transformation happens at access time, you can replace methods at runtime and all existing instances will see the new behavior:

class Logger:
    def log(self, msg: str) -> None:
        print(f"LOG: {msg}")

# Replace the method on the class
Logger.log = lambda self, msg: print(f"[DEBUG] {msg}")

l = Logger()
l.log("test")
# [DEBUG] test

This works because l.log triggers the descriptor protocol on whatever function is currently in Logger.__dict__['log']. The instance does not cache the old method.

The self parameter is a convention, not a requirement. Python does not care what you name the first parameter of a method. It will work with any name. The community convention of self (and cls for class methods) exists purely for readability:

class Oddball:
    def greet(this):
        return f"Hello from {this}"

o = Oddball()
print(o.greet())
# Hello from <__main__.Oddball object at 0x...>

The Complete Mental Model

Every def statement produces a function object. Functions are non-data descriptors because they implement __get__ but not __set__. When a function lives in a class's __dict__ and is accessed through an instance, the descriptor protocol calls function.__get__(instance, class), which returns a bound method: a thin wrapper that calls the original function with the instance prepended to the arguments.

That is the entire mechanism. There is no "method" keyword, no special "method type" that def creates inside a class, no hidden compiler flag. There is a function, a descriptor protocol, and an attribute lookup chain that walks the MRO. Everything else — staticmethod, classmethod, property, and the elegant polymorphism of Python's object system — is built on this same foundation.

The Takeaway

When you understand the descriptor protocol, you stop memorizing rules and start seeing the machinery. That is comprehension, not copy-paste.

back to articles