The hidden cost of Python instance methods in distributed systems
TLDRs
- Prefer static methods when possible â they have zero binding overhead and serialize cleanly in distributed systems
- Force garbage collection with
gc.collect()
when you're done with instance methods that hold large object references - Avoid instance method references in long-lived queues â they prevent garbage collection of entire object graphs
- Use class methods for factory patterns â they're lighter than instance methods but still maintain class context
I've been working on scaling up our AI backend through the brute force of distributed workload management. In doing so, I've been shifting the codebase to a simpler, function-based approach. The increased composability has actually been a real help with setting up infrastructure.
This work led me down an excellent rabbit hole into Python's method systemâspecifically how staticmethod
, classmethod
, and instance methods work under the hood. Turns out, it's just functions with different degrees of binding.
Python's Method Storage Architecture
Here's what I discovered when I started digging into how Python actually stores methods:
class Example:
@staticmethod
def static_func(): pass
@classmethod
def class_func(cls): pass
def instance_func(self): pass
print(Example.__dict__)
# Output:
# {
# 'static_func': <staticmethod object at 0x...>,
# 'class_func': <classmethod object at 0x...>,
# 'instance_func': <function Example.instance_func at 0x...>
# }
It might be tempting to think instance methods are simply generic functions while static and class methods are subclassesâbut it's more nuanced:
staticmethod
andclassmethod
are descriptor classes that wrap functions- Instance methods are stored as raw function objects
- Python's descriptor protocol (
__get__
) transforms them during attribute access
# At ATTRIBUTE ACCESS time (getattr/dot notation):
instance = Example()
instance.static_func # staticmethod.__get__() returns unwrapped function
instance.class_func # classmethod.__get__() returns bound method
instance.instance_func # function.__get__() returns bound method
What this means is:
- Static methods are like tools in a shared toolboxâthey exist independently and don't need any context about the class or instance
- Class methods are like factory instructionsâthey're bound to the class itself and can create new instances or access class-level data
- Instance methods are like personalized proceduresâthey're bound to specific instances and can access that instance's unique state
- The magic happens at access time, not definition timeâPython decides what to return based on how you're accessing the method
The Descriptor Protocol: Where the Magic Happens
Now here's where things get interesting. The behavior I described aboveâwhere the same method definition can return different things depending on how you access itâis powered by Python's descriptor protocol.
What is a descriptor? It's Python's way of customizing what happens when you access an attribute. Any object that defines __get__
, __set__
, or __delete__
becomes a descriptor. This is crucial because it's the mechanism that transforms static storage into dynamic behavior.
Think about it: when you define a method with @staticmethod
, Python doesn't magically create a different kind of function. Instead, it wraps your function in a staticmethod
descriptor that knows how to behave differently when accessed. This uniform interface is what allows Python to have such flexible method types while keeping the underlying implementation simple.
# What happens during attribute access:
# Static method access
instance.static_func
# â staticmethod.__get__(instance, type(instance))
# â returns unwrapped function (no binding)
# Class method access
instance.class_func
# â classmethod.__get__(instance, type(instance))
# â returns function bound to class
# Instance method access
instance.instance_func
# â function.__get__(instance, type(instance))
# â returns function bound to instance
Why This Actually Matters
Here's where this gets interesting for practical work. Every time you access an instance method, Python creates a bound method object that holds references to both the function and the instance. Static methods just... don't do that.
I started noticing this when profiling some tight loops in our preprocessing pipeline. Static methods were consistently faster, but more importantly, the memory behavior was totally different in distributed setups.
Here's what I've run into:
- Static methods: Zero binding overheadâthey're just functions
- Class methods: Single class bindingâminimal overhead
- Instance methods: Instance binding + potential cache misses
Here are some gotchas I've encountered in distributed environments:
Serialization weirdness: When you pickle an instance method, you're actually serializing the entire instance along with it. Static methods serialize as just the function, which is way cleaner for inter-process communication.
Memory leaks you didn't expect: Instance methods can create sneaky references that prevent garbage collection in worker processes. I had a case where a 2GB model stayed in memory because of a single method reference sitting in a task queue.
Binding overhead adds up: In hot paths across distributed workers, that method binding cost accumulates. Switching our preprocessing functions from instance methods to static methods gave us a noticeable speedup in per-batch processing.
The Function That Started This Deep Dive
The utility function below came out of a specific problem: I wanted to dynamically register functions from class namespaces without manually maintaining lists. Instead of writing [MyClass.func1, MyClass.func2, ...]
everywhere, I could just extract them programmatically.
This became especially useful for our distributed task registryâI could define processing pipelines as classes with static methods, then automatically register all the functions for remote execution:
def extract_class_methods(
class_instance: object,
*,
get_static_methods: bool = False,
get_class_methods: bool = False,
get_instance_methods: bool = True,
include_private: bool = False,
name_filter: Optional[Callable[[str], bool]] = None,
) -> List[Callable]:
"""Extract methods from a class instance with fine-grained control.
Args:
class_instance: Instance object to extract methods from
get_static_methods: Include static methods
get_class_methods: Include class methods
get_instance_methods: Include instance methods
include_private: Include methods starting with underscore
name_filter: Optional predicate to filter method names
Returns:
List of callable methods (bound for instance methods, unbound for static/class)
Example:
class MyClass:
@staticmethod
def static_func(): pass
@classmethod
def class_func(cls): pass
def instance_func(self): pass
instance = MyClass()
# Get only static methods
statics = extract_class_methods(
instance,
get_static_methods=True,
get_instance_methods=False
)
# Get all methods with name filter
mappers = extract_class_methods(
instance,
name_filter=lambda n: n.startswith("to_")
)
"""
def should_include_method(name: str) -> bool:
"""Check if method should be included based on name criteria."""
if name_filter is not None and not name_filter(name):
return False
if name.startswith("__") and name.endswith("__"):
return False
if not include_private and name.startswith("_"):
return False
return True
def classify_method(target_class: type, name: str, method: object) -> str:
"""Classify method type by inspecting the class dictionary."""
if name in target_class.__dict__:
descriptor = target_class.__dict__[name]
if isinstance(descriptor, staticmethod):
return "static"
elif isinstance(descriptor, classmethod):
return "class"
# For inherited methods or regular functions
if callable(method):
return "instance"
return "unknown"
def extract_callable(
name: str, method: object, method_type: str
) -> Optional[Callable]:
"""Extract the callable from the method object based on its type."""
if method_type == "static":
# Static methods: return the unwrapped function
descriptor = target_class.__dict__[name]
return descriptor.__func__
elif method_type == "class":
# Class methods: return the unwrapped function
descriptor = target_class.__dict__[name]
return descriptor.__func__
elif method_type == "instance":
# Instance methods: return the bound method
bound_method = getattr(class_instance, name)
if callable(bound_method) and not isinstance(bound_method, type):
return bound_method
return None
def method_type_requested(method_type: str) -> bool:
"""Check if this method type was requested."""
return (
(method_type == "static" and get_static_methods)
or (method_type == "class" and get_class_methods)
or (method_type == "instance" and get_instance_methods)
)
# Validation
if not any([get_static_methods, get_class_methods, get_instance_methods]):
raise ValueError("At least one method type must be selected")
if inspect.isclass(class_instance):
raise TypeError(f"Expected instance, got class {class_instance}")
if not hasattr(class_instance, "__class__"):
raise TypeError(f"Expected instance, got {type(class_instance)}")
if isinstance(class_instance, str):
raise TypeError(f"Expected instance, got string: '{class_instance}'")
target_class = class_instance.__class__
methods = []
for name in dir(target_class):
if not should_include_method(name):
continue
method = getattr(target_class, name)
if not callable(method):
continue
method_type = classify_method(target_class, name, method)
callable_method = extract_callable(name, method, method_type)
if callable_method is not None and method_type_requested(method_type):
methods.append(callable_method)
return methods
... Thats all for now.