Bound, Unbound, and Static Methods in Python (Deep Dive)

I still see seasoned Python teams trip over method binding when a bug shows up in production logs like: TypeError: missing 1 required positional argument. The code looks fine, the method call looks fine, and yet the error is real. In my experience, the root cause is almost always a fuzzy mental model of what Python does when you access a function through a class or an instance. Binding is not just a “class thing”; it is a protocol-level feature driven by descriptors and attribute lookup rules. Once you see the mechanics, the confusing edge cases become predictable, and you can design APIs that are clear and hard to misuse. In this post I walk you through bound methods, the historical idea of unbound methods, and static methods as the modern, explicit way to say “this should behave like a plain function.” I will show runnable code, highlight common mistakes, and give you concrete guidance on when to choose instance, class, or static methods in real-world systems.

The core model: functions become methods through binding

The most important thing to know is that a function defined inside a class is just a function object stored in the class dictionary. It becomes a method only when it is accessed through an instance or a class in a way that triggers the descriptor protocol. Python’s function objects implement get, and that hook is where binding happens. I use this mental model: the function is a raw tool on a shelf, and binding is the act of handing it to a specific worker with their badge already attached. The attached badge is the instance (self) for instance methods or the class (cls) for class methods.

When you access a function via an instance, Python creates a bound method object. This object already knows the instance, and calling it will insert that instance as the first argument. That is why you write obj.method() instead of Class.method(obj) in everyday code. It is also why a method without self as the first parameter raises a TypeError when called through an instance.

Here is a minimal runnable example that shows binding in action and exposes the internal parts of a bound method:

class Reporter:

def report(self, tag):

return f‘{tag}: {self.value}‘

r = Reporter()

r.value = 42

This is a bound method: the instance is attached

m = r.report

print(m) # <bound method Reporter.report of <main.Reporter object at ...>>

print(m.self is r) # True

print(m.func is Reporter.report) # True

print(m(‘status‘)) # status: 42

This is the raw function, no instance attached

f = Reporter.report

print(f) #

print(f(r, ‘status‘)) # status: 42

You can see that the bound method is just a small wrapper with two key slots: self points to the instance, and func points to the original function. That is the whole mechanism. Everything else in this post is a consequence of that simple design.

Bound methods: instance methods with context

Bound methods are what most people mean when they say “method.” These are instance methods that receive self as the first argument and can read or modify instance state. I think of them as operations that must know which object they are acting on, like a delivery address attached to a package. Without the address, the carrier does not know where to deliver.

Here is a complete example that mirrors a real-world domain object. Notice that rename only changes the specific instance you call it on.

class Account:

def init(self, handle):

self.handle = handle

self.active = True

def deactivate(self):

self.active = False

def rename(self, new_handle):

# Changing only this instance

self.handle = new_handle

def label(self):

return f‘@{self.handle} (active={self.active})‘

alice = Account(‘alice‘)

bob = Account(‘bob‘)

print(alice.label()) # @alice (active=True)

print(bob.label()) # @bob (active=True)

bob.rename(‘bobby‘)

bob.deactivate()

print(alice.label()) # @alice (active=True)

print(bob.label()) # @bobby (active=False)

The key detail is that a bound method call like bob.rename(‘bobby‘) expands to Account.rename(bob, ‘bobby‘). If you remember that single expansion rule, method call errors suddenly become easy to debug. If you forget self in the method definition, the expansion still happens and you get a mismatch between the number of arguments the function expects and the number it receives.

When bound methods are the right choice

I reach for bound methods when the operation:

  • Reads or writes instance attributes
  • Needs polymorphic behavior across subclasses
  • Represents the core behavior of the object in its lifecycle

I avoid bound methods when I would be forced to fake self just to use a method name. In those cases, a static method or a top-level function communicates intent better.

The idea of unbound methods and why Python 3 removed them

If you learned Python in the 2.x era, “unbound methods” referred to functions accessed through the class, like Account.rename, which returned a special object that checked whether the first argument was an instance of the class. This check was a guardrail: calling Account.rename(123, ‘x‘) would fail before the function body ran.

Python 3 removed unbound methods. Today, accessing a function via the class returns the raw function, not a special unbound method wrapper. That means Python 3 trusts you to pass a correct instance if you call a method through the class. Here is a quick demonstration:

class Counter:

def init(self):

self.value = 0

def bump(self, step):

self.value += step

c = Counter()

Access through the class gives the raw function

print(Counter.bump) #

You must pass the instance explicitly

Counter.bump(c, 3)

print(c.value) # 3

Passing the wrong type fails later, inside the method

Counter.bump(‘not an instance‘, 2) # AttributeError at runtime

This matters for code review and debugging. In Python 3, a mistaken call through the class can fail in a less obvious place, because the guardrail is gone. The best defense is clear method naming and tests that cover misuse. In my experience, if you allow a method to be called through the class, you should treat it like a public API and document it.

Static methods: explicit, unbound behavior

Static methods are the modern, explicit way to say “this function lives in the class namespace, but it does not need self or cls.” They are still attributes of the class, so they are grouped with related behavior, but they do not participate in binding. I use static methods when I want to keep a utility function close to the data model or when I want to avoid polluting the module namespace.

A static method is created with the @staticmethod decorator or by wrapping a function with staticmethod() after the fact. The decorator form is what I see in modern codebases.

class Geo:

@staticmethod

def milestokm(miles):

return miles * 1.60934

@staticmethod

def kmtomiles(km):

return km / 1.60934

print(Geo.milestokm(3.1)) # 4.988954

print(Geo.kmtomiles(5)) # 3.106855

The static method receives no automatic first argument. You can call it from the class or from an instance, and it behaves the same. That is both a feature and a hazard. I recommend calling static methods from the class to signal intent and avoid confusion.

When a static method is the right choice

I reach for static methods when the operation:

  • Does not touch instance or class state
  • Is tightly related to the class domain
  • Benefits from namespacing inside the class

If the function does not logically belong to the class, I put it at module level instead. Namespacing is not free; it adds a layer of cognitive load for your readers.

Comparing instance, class, and static methods in real code

In practice, you will choose among instance methods, class methods, and static methods. Class methods receive the class as cls and are a middle ground: they can read class state or construct new instances. I teach teams this framing: instance methods act on a specific object, class methods act on the factory, static methods act on the toolbox.

Here is a side-by-side example you can run as-is:

class Token:

prefix = ‘tok_‘

def init(self, raw):

self.raw = raw

def is_prefixed(self):

return self.raw.startswith(self.prefix)

@classmethod

def frompayload(cls, payloadid):

return cls(f‘{cls.prefix}{payload_id}‘)

@staticmethod

def sanitize(value):

return ‘‘.join(ch for ch in value if ch.isalnum())

t = Token.from_payload(‘abc-123‘)

print(t.raw) # tok_abc-123

print(t.is_prefixed()) # True

print(Token.sanitize(‘a b‘)) # ab

I recommend this rule set for production code:

  • If the method needs instance data, make it an instance method.
  • If the method needs class data or creates instances, make it a class method.
  • If the method needs neither, make it a static method or a top-level function.

Traditional vs modern method design

Python 2 style allowed unbound methods with type checks. Modern Python is explicit: if you want method-like behavior without binding, you say so with @staticmethod or @classmethod.

Dimension

Traditional unbound method (Python 2 style)

Modern explicit method (Python 3+) —

— Binding behavior

Implicit wrapper for class access

No wrapper; raw function or static method Type checks

Automatic instance check on call

No automatic check unless you add it Error location

Fails early at call site

Fails later, often inside method Clarity

Implicit, easy to misunderstand

Explicit, easy to reason about Recommended today

No (removed)

Yes, use explicit decorators

I prefer the modern style because it makes intent clear and works well with type checkers and linters in 2026 workflows.

Common mistakes I see in code reviews

Bound methods and static methods are simple, but mistakes are surprisingly common. Here are the patterns I flag most often and how I correct them.

1) Forgetting self

If you define a method without self and call it on an instance, you get a TypeError because Python still passes the instance. The fix is to add self or mark the method as static.

class Profile:

def label():

return ‘profile‘

p = Profile()

p.label() -> TypeError: label() takes 0 positional arguments but 1 was given

Correction:

class Profile:

def label(self):

return ‘profile‘

2) Using a static method when you need class configuration

If the method depends on class-level settings, use @classmethod instead of @staticmethod. Static methods cannot see cls unless you pass it explicitly, which hides intent.

3) Shadowing a function on the instance

If you set an attribute with the same name as a method, you override it on that instance only. This can break method calls in confusing ways.

class User:

def greet(self):

return ‘hello‘

u = User()

print(u.greet())

u.greet = ‘not a method anymore‘

print(u.greet) # ‘not a method anymore‘

This is why I avoid setting instance attributes that reuse method names and rely on linters to catch it.

4) Calling instance methods through the class without a real instance

In Python 3, Class.method(obj) will accept any object and may fail deep inside the method. If you want enforcement, add explicit checks at the top of the method or rely on type checking tools.

5) Overusing static methods when a module-level function is clearer

Static methods can become a dumping ground. If a function is not tightly related to the class domain, I move it to the module. This reduces mental load and makes testing simpler.

Performance and design trade-offs

Method binding in Python is lightweight, but it is not free. Binding involves attribute lookup and creation of a small wrapper object. In most applications, the overhead is tiny, typically in the 10–50 ns range for the binding itself and a few hundred ns for the call overhead, depending on the interpreter and the environment. In real systems, the time you spend in the method body dominates.

That said, there are two practical performance considerations I do bring up in reviews:

  • If you are calling a hot loop of tiny methods millions of times, caching the bound method in a local variable can shave a small amount of overhead. For example, m = obj.method outside the loop is faster than obj.method() inside each iteration. The improvement is usually measurable only in microbenchmarks.
  • Static methods do not incur binding overhead, but the difference is small. I never choose static methods purely for speed; I choose them for clarity.

Design trade-offs are more important than micro performance. Using the right method type communicates intent to your future self and to your tooling. Type checkers, IDEs, and even AI assistants in 2026 make much better suggestions when your method types match the semantic purpose of the code.

Practical patterns I recommend in 2026

Modern Python development is as much about tooling and clarity as it is about syntax. Here are patterns I use and recommend:

1) Type hints that clarify binding

Type hints make method roles explicit. For instance methods, the type of self is inferred. For class methods and static methods, annotations make the intent clear to readers and tools.

from future import annotations

from dataclasses import dataclass

@dataclass

class OrderId:

value: str

def is_valid(self) -> bool:

return self.value.startswith(‘ord_‘)

@classmethod

def from_int(cls, n: int) -> OrderId:

return cls(f‘ord_{n}‘)

@staticmethod

def sanitize(raw: str) -> str:

return ‘‘.join(ch for ch in raw if ch.isalnum() or ch == ‘_‘)

2) API design that prevents misuse

If a method should never be called through the class, I keep it private with a leading underscore and avoid referencing it from class contexts. This is not enforced by Python, but it sets a clear convention.

3) Tests that lock in method roles

I like to include tests that confirm method usage patterns. A small unit test can catch an accidental change from @classmethod to @staticmethod or a missing self parameter. These tests take seconds and save hours of debugging.

4) AI-assisted code review

In 2026, I run automated reviews that flag method-type mismatches. These tools are good at catching cases where a method never uses self and could be static, or where a static method should be a class method because it reads class config. I still make the final decision, but automation keeps the boring mistakes out of production.

Deeper mechanics: descriptor protocol and attribute lookup

If you want a mental model that makes every edge case click, learn two facts:

1) Functions implement the descriptor protocol via get.

2) Attribute lookup in Python checks the instance dictionary first, then the class dictionary, then base classes.

Let me show you a tiny class that prints when binding happens by manually implementing get.

class VerboseFunction:

def init(self, func):

self.func = func

def get(self, instance, owner):

if instance is None:

print(‘Accessed through class: return raw function‘)

return self.func

print(‘Accessed through instance: return bound wrapper‘)

def bound(args, *kwargs):

return self.func(instance, args, *kwargs)

return bound

class Demo:

def init(self, value):

self.value = value

def show(self, x):

return self.value + x

Replace Demo.show with a verbose descriptor

Demo.show = VerboseFunction(Demo.show)

d = Demo(10)

print(Demo.show) # class access

print(d.show) # instance access

print(d.show(5)) # call

You rarely need to write custom descriptors, but understanding them explains why methods behave the way they do and why @staticmethod and @classmethod can be implemented in pure Python. Those decorators return objects with custom get behavior.

The attribute lookup rule also explains why instance attributes can shadow methods. If u.greet exists in the instance dictionary, Python never looks in the class dictionary where the method lives. This is a powerful feature and a common foot-gun.

When unbound method confusion still appears in Python 3

Even though unbound methods are gone, the term still appears in conversations. Here are two modern contexts where “unbound” is used informally:

1) Accessing a function through the class

People say “unbound method” when they really mean “raw function stored on the class.” The more precise phrasing is “function accessed through the class.” That raw function has no self and needs the instance passed in.

2) Storing function objects as attributes

You can store any function object anywhere. If you assign a function to a class attribute, it becomes a descriptor automatically (because it’s a function type). But if you assign a function to an instance attribute, it stays a plain function. This leads to surprising differences in call signatures.

class Widget:

def ping(self):

return ‘ping‘

w = Widget()

Class attribute: function becomes method

print(w.ping())

Instance attribute: function stays plain

w.ping = lambda: ‘instance ping‘

print(w.ping())

No magic here: it is just attribute lookup plus the descriptor protocol.

Edge cases that bite in production

If you work on larger systems, you will eventually encounter one of these. I’ve seen all of them in codebases that “seemed clean.”

1) Monkey-patching methods at runtime

Dynamic patching is common in tests or instrumentation. When you assign a function to a class, it becomes a descriptor. When you assign a function to an instance, it stays a plain function.

def new_label(self):

return f‘patched:{self.handle}‘

Account.label = new_label

print(alice.label()) # now bound to alice as expected

bob.label = new_label

print(bob.label()) # TypeError: missing 1 required positional argument

This is because bob.label is a plain function, and Python does not bind it. If you must patch at the instance level, use types.MethodType to bind explicitly.

import types

bob.label = types.MethodType(new_label, bob)

print(bob.label()) # works

2) Overriding methods with @staticmethod in subclasses

If a base class defines an instance method and a subclass replaces it with a static method, code that expects instance semantics will break, sometimes silently.

class Base:

def connect(self, url):

return f‘base:{url}‘

class Child(Base):

@staticmethod

def connect(url):

return f‘child:{url}‘

c = Child()

print(c.connect(‘x‘)) # works but semantics changed

If the base class calls self.connect(...), it will now call a static method that ignores self. Sometimes that is intentional, but more often it is accidental. I treat this as a design smell and document it explicitly if it is needed.

3) Accidentally turning a function into a descriptor

A subtle trap: any function assigned to a class attribute becomes a descriptor and thus binds. This can surprise you if you meant to store a callable without binding behavior.

If you truly want a callable that does not bind, wrap it in staticmethod or assign it as a staticmethod object.

class Registry:

def init(self):

self.handlers = []

def add(self, fn):

self.handlers.append(fn)

This function will become a descriptor if put on a class

def handler(x):

return x * 2

class Box:

handler = staticmethod(handler) # prevent binding

4) Passing bound methods around in async code

Bound methods capture self, which can keep objects alive longer than intended. In async systems, this can create memory pressure if you store bound methods in long-lived queues. If you only need the function, store type(obj).method plus the instance separately, or store a weak reference to the instance.

Practical scenarios: which method type should I choose?

Let’s ground this in real design decisions rather than abstract rules.

Scenario 1: Constructor alternatives

If you need multiple ways to build an object, use a class method so subclasses can override the factory.

class Image:

def init(self, data, fmt):

self.data = data

self.fmt = fmt

@classmethod

def from_file(cls, path):

with open(path, ‘rb‘) as f:

data = f.read()

fmt = path.split(‘.‘)[-1].lower()

return cls(data, fmt)

If you made this a static method that returns Image(...), subclassing would break. Class methods preserve polymorphism.

Scenario 2: Domain-specific utilities

If a helper function is used almost exclusively with a class and benefits from name grouping, make it static.

class Email:

@staticmethod

def normalize(address):

return address.strip().lower()

But if the helper does not logically belong to the class, keep it at module level instead.

Scenario 3: Multi-tenant settings

If behavior depends on class-level configuration (like API defaults), use class methods.

class Client:

base_url = ‘https://example.com‘

@classmethod

def withbaseurl(cls, base_url):

cls.baseurl = baseurl

return cls

A static method here would hide the fact that the class is being mutated.

Scenario 4: Mutating state

If the method changes object state, it should almost always be an instance method. That includes caches, counters, and state flags.

Binding and inheritance: why class methods matter

Inheritance makes the difference between @classmethod and @staticmethod much more important. The key idea is: class methods respect the class you call them on, which supports polymorphism and mixins.

class Shape:

def init(self, name):

self.name = name

@classmethod

def from_config(cls, cfg):

return cls(cfg[‘name‘])

class Circle(Shape):

def init(self, name, radius):

super().init(name)

self.radius = radius

@classmethod

def from_config(cls, cfg):

return cls(cfg[‘name‘], cfg[‘radius‘])

cfg = {‘name‘: ‘c1‘, ‘radius‘: 5}

shape = Circle.from_config(cfg)

print(type(shape)) # Circle

If you used a static method that always returned Shape(...), you would lose the subclass type. This is why factory methods are almost always class methods.

Testing patterns for binding-related bugs

I want tests that fail loudly when someone changes a method signature or decorator incorrectly. Here is a minimal pattern I use:

def testtokenfactoryusesclass():

class Base:

def init(self, value):

self.value = value

@classmethod

def make(cls, value):

return cls(value)

class Sub(Base):

pass

obj = Sub.make(‘x‘)

assert isinstance(obj, Sub)

This test fails if make becomes a static method that returns Base(value). It also fails if someone removes @classmethod and forgets to pass cls in a refactor.

Using descriptors to build your own method types

Advanced but incredibly useful: you can implement your own method-like objects using descriptors. This is how @staticmethod and @classmethod work internally. The pattern is straightforward:

  • get(self, instance, owner) decides what you return.
  • If you return a function that closes over instance, you have created a bound method.
  • If you return the raw function, you have a static-like behavior.

This is useful for APIs that need to customize how methods are bound (for example, injecting a context object or a dependency at access time). It is also a reminder that method binding is not “magic,” just a protocol.

A deeper look at @staticmethod and @classmethod

If you like to see the mechanics, here are simplified versions of the built-in behaviors:

class simple_staticmethod:

def init(self, func):

self.func = func

def get(self, instance, owner):

return self.func

class simple_classmethod:

def init(self, func):

self.func = func

def get(self, instance, owner):

def bound(args, *kwargs):

return self.func(owner, args, *kwargs)

return bound

This shows you exactly why static methods do not receive self or cls and why class methods do. The real implementations are optimized and handle edge cases, but the semantics are the same.

How I decide: a practical checklist

When I review code or design a class, I ask four questions. This takes 10 seconds and saves a lot of debate.

1) Does it mutate or read instance state?

If yes, make it an instance method.

2) Does it need the class to build or configure instances?

If yes, make it a class method.

3) Is it a related utility that doesn’t touch state?

If yes, consider a static method.

4) Does it really belong to this class?

If no, put it at module level.

Pitfall: method binding and default arguments

One subtle bug: using self or cls inside default arguments. Defaults are evaluated at function definition time, not at call time, so you cannot reference self or cls there.

class Cache:

def get(self, key, default=self): # NameError at definition time

...

This is not specific to methods, but it appears more often in methods because people forget the binding model. The fix is to use None and set the default in the body.

Practical mini-case studies

These are small, real-looking scenarios where method choice matters.

Case study 1: Registry pattern

You have a class that tracks subclasses and you want to register them by name.

class Handler:

registry = {}

def init_subclass(cls, kwargs):

super().init_subclass(kwargs)

Handler.registry[cls.name] = cls

@classmethod

def from_name(cls, name, args, *kwargs):

return cls.registry<a href="args, *kwargs">name

from_name must be a class method because it uses class-level data and returns class instances. A static method would obscure this relationship.

Case study 2: Validation helper on a dataclass

You have a small dataclass and a validation function that does not need instance state.

from dataclasses import dataclass

@dataclass

class TaxId:

value: str

@staticmethod

def is_valid(value: str) -> bool:

return value.isdigit() and len(value) in (9, 10)

This is a good static method: it belongs with the type but needs no instance.

Case study 3: Subclass-aware constructors

You want to parse data into a subclass with custom behavior.

class Event:

def init(self, ts, kind):

self.ts = ts

self.kind = kind

@classmethod

def from_dict(cls, data):

return cls(data[‘ts‘], data[‘kind‘])

class LoginEvent(Event):

def init(self, ts, kind, user):

super().init(ts, kind)

self.user = user

@classmethod

def from_dict(cls, data):

return cls(data[‘ts‘], data[‘kind‘], data[‘user‘])

Class methods are a must here to preserve subclass type and allow overriding.

Common refactors and how to do them safely

If you are changing method types, here is how I do it without breaking callers.

Refactor: instance method → static method

Only safe if the method never uses self and no one depends on instance dispatch (like overriding in subclasses). Steps:

1) Add @staticmethod.

2) Remove self parameter.

3) Update call sites if any pass self explicitly.

4) Add a test that calling via the class still works.

Refactor: static method → class method

Use when you start needing class config or want to return subclass instances.

1) Change decorator to @classmethod.

2) Add cls parameter.

3) Replace hard-coded class names with cls.

4) Add tests for subclass behavior.

Refactor: static/class method → instance method

Use when you add state usage or want polymorphism on instances.

1) Change decorator (or remove it).

2) Add self parameter.

3) Update call sites to call on instances, not the class.

4) Consider where to get or create instances in calling code.

How binding interacts with decorators

Decorators can hide or alter method binding, especially if they return functions that are not descriptors. A common trap: decorators that wrap a function but do not preserve the descriptor protocol. The fix is to use functools.wraps and to design decorators that return function objects (which are descriptors) rather than custom callables.

Here is a safe decorator for methods:

import functools

def log_call(fn):

@functools.wraps(fn)

def wrapper(args, *kwargs):

print(f‘calling {fn.name}‘)

return fn(args, *kwargs)

return wrapper

class Demo:

@log_call

def ping(self):

return ‘pong‘

Because wrapper is still a function object, it remains a descriptor and binds correctly.

If your decorator returns an object with a call method, you must implement get too, or your method will no longer bind.

Why static methods can be a smell

I do use static methods, but I use them intentionally and sparingly. Overuse often signals that the class is becoming a “bucket of functions” rather than a coherent object. In those cases, I consider:

  • Moving the function to module level
  • Extracting a utility class
  • Re-evaluating whether the class is doing too much

Static methods are a convenience, not a design pattern. They are best when the function is tightly coupled to the class concept but does not need state.

Method binding in data models and ORMs

If you work with data models (ORMs, Pydantic-like models, dataclasses), method binding can affect serialization and validation patterns. Two common rules I follow:

  • Use instance methods for validation that depends on existing field values.
  • Use class methods for constructors that parse external data or default values.

Example:

from dataclasses import dataclass

@dataclass

class UserRecord:

id: str

email: str

def is_internal(self) -> bool:

return self.email.endswith(‘@example.com‘)

@classmethod

def from_row(cls, row: dict) -> ‘UserRecord‘:

return cls(row[‘id‘], row[‘email‘])

@staticmethod

def normalize_email(email: str) -> str:

return email.strip().lower()

This pattern keeps responsibilities clean and makes each method’s binding meaningful.

Debugging method binding errors quickly

When you see an error like TypeError: missing 1 required positional argument, I follow this checklist:

1) Inspect how the method is defined. Does it include self or cls?

2) Inspect how it is called. Is it called on the instance or the class?

3) Inspect whether the method name was shadowed on the instance.

4) Inspect decorators. Did a wrapper break binding?

If I need to dig deeper, I use inspect:

import inspect

print(inspect.signature(obj.method))

print(obj.method.self)

print(obj.method.func)

These details tell me whether I’m dealing with a bound method, a raw function, or a shadowed attribute.

Alternative approaches when binding feels awkward

Sometimes the method model itself is the problem. Two alternatives I consider:

1) Module-level functions

If you keep passing self around or the class is only used as a namespace, it may be simpler to use module functions and a plain data class.

2) Composition instead of inheritance

If you need different behaviors but are fighting method binding and overriding, composition can be clearer. Inject a strategy object rather than overriding methods in subclasses. This reduces reliance on class methods and static methods entirely.

A note on style guides and team consistency

In larger teams, binding mistakes often come from inconsistent style decisions. I recommend agreeing on rules like:

  • Static methods must be called via the class.
  • Factory methods must be class methods.
  • Instance methods must never be called via the class in production code.

These rules make code easier to read and review. They also make errors more obvious because a call that violates the rule “looks wrong.”

Summary: a simple mental model that scales

If you remember nothing else, remember this:

  • A function in a class is just a function until accessed.
  • Instance access binds self and returns a bound method.
  • Class access returns the raw function unless it is a class or static method.

Everything else is a design choice. Choose bound methods for instance behavior, class methods for class-aware factories and configuration, and static methods for related utilities that don’t touch state. When in doubt, keep functions at module level and keep classes focused.

Quick reference checklist

Use this as a pocket guide in code reviews:

  • Needs instance state? → instance method
  • Needs class state or constructs instances? → class method
  • Needs neither, but belongs to class concept? → static method
  • Doesn’t belong to class? → module-level function

Binding in Python is not complicated once you see it as a descriptor protocol plus attribute lookup. The benefit of mastering it is outsized: you debug faster, design APIs that prevent misuse, and build systems that are easier to extend. That is why I spend time teaching it, and why I recommend every Python team agree on a shared, explicit model of method binding.

Scroll to Top