Python Method Decorator: A Practical Guide
Learn how to write and use python method decorators effectively—covering syntax, metadata preservation, arguments, class-based decorators, and practical pitfalls.
A python method decorator is a callable that takes a method as its argument and returns a modified method. The @decorator syntax is syntactic sugar for method = decorator(method). This pattern lets you wrap behavior around existing methods without changing their source code, which is useful for logging, timing, access control, or input validation.
What a Method Decorator Does in Python
When you apply a decorator to a method, Python evaluates the decorator expression at function definition time. The decorator receives the original method object and returns a new callable that typically replaces it. The new callable can run code before and after the original method, modify arguments, or change the return value.
Consider a simple decorator that prints a message before the method runs:
def log_call(method): def wrapper(*args, **kwargs): print(f"Calling {method.__name__}") return method(*args, **kwargs) return wrapper class Service: @log_call def fetch(self, url): return f"data from {url}"
Here, fetch is replaced by wrapper. When you call Service().fetch("/api"), the wrapper prints the method name and then delegates to the original method. The decorator works on instance methods because self is passed as the first positional argument to wrapper.
Writing a Basic Method Decorator
The simplest decorator takes no arguments and returns a wrapper. The wrapper must accept *args and **kwargs to handle any method signature. It should return the result of the original method so the caller sees the same behavior.
def time_it(method): import time def wrapper(*args, **kwargs): start = time.perf_counter() result = method(*args, **kwargs) elapsed = time.perf_counter() - start print(f"{method.__name__} took {elapsed:.4f} seconds") return result return wrapper class Report: @time_it def generate(self, rows): return sum(row for row in rows)
This decorator measures execution time and prints it. It does not change the return value. If the original method raises an exception, the wrapper propagates it, so the decorator does not alter error behavior unless you explicitly catch exceptions.
Preserving Function Metadata with functools.wraps
Without care, a decorator replaces the original method's name, docstring, and other metadata with those of the wrapper. This breaks tools that rely on introspection, such as debuggers, documentation generators, and some testing frameworks. The functools.wraps helper copies the original method's metadata onto the wrapper.
from functools import wraps def log_call(method): @wraps(method) def wrapper(*args, **kwargs): print(f"Calling {method.__name__}") return method(*args, **kwargs) return wrapper class Service: @log_call def fetch(self, url): """Fetch data from the given URL.""" return f"data from {url}" print(Service.fetch.__name__) # fetch print(Service.fetch.__doc__) # Fetch data from the given URL.
Using @wraps also updates __module__, __annotations__, and __dict__. It is considered a best practice for any decorator that wraps a function or method, because it preserves the original callable's identity.
Decorators That Accept Arguments
Sometimes you need to parameterize a decorator, such as specifying a log level or a retry count. This requires an extra layer of indirection: the decorator factory returns the actual decorator.
def retry(max_attempts): def decorator(method): @wraps(method) def wrapper(*args, **kwargs): for attempt in range(max_attempts): try: return method(*args, **kwargs) except Exception: if attempt == max_attempts - 1: raise return wrapper return decorator class Client: @retry(max_attempts=3) def send(self, payload): # network call that may fail return "ok"
When you write @retry(max_attempts=3), Python first calls retry(3) to get decorator, then applies decorator to send. The wrapper now has access to max_attempts via closure. This pattern is common for configuration-driven behavior.
Using Class-Based Decorators for Stateful Behavior
A decorator can be a class that implements __call__. This is useful when the decorator needs to maintain state across multiple calls, such as counting invocations or caching results.
class CountCalls: def __init__(self, method): self.method = method self.count = 0 def __call__(self, *args, **kwargs): self.count += 1 print(f"Call {self.count} of {self.method.__name__}") return self.method(*args, **kwargs) class API: @CountCalls def get(self, endpoint): return f"data for {endpoint}" api = API() api.get("/users") api.get("/orders")
Here, the decorator instance is created once when the method is defined. Each call to the method invokes __call__, which increments the counter. This approach is more verbose than a function-based decorator but can be clearer when the decorator holds mutable state.
Performance and Maintainability Considerations
Decorators add a layer of indirection, which has a small runtime cost. The overhead of a simple wrapper is usually negligible compared to the method's actual work, but for very hot paths—methods called millions of times in a tight loop—the extra function call and attribute lookups can matter. If you measure and find a bottleneck, consider inlining the logic or using a decorator that only wraps when needed.
Maintainability is more significant. A decorator can obscure the original method's behavior, especially if it modifies arguments or return values. Keep decorators focused on one concern and document them clearly. Overusing decorators can make code harder to debug because the call stack includes multiple wrapper layers. Use them where the cross-cutting concern is genuinely shared across many methods.
Common Pitfalls and How to Avoid Them
One common mistake is forgetting to use @wraps, which breaks introspection. Another is writing a decorator that does not accept *args and **kwargs, causing errors when the method has parameters. Also, be careful when decorating class methods: self is passed as the first argument, so the wrapper must accept it. If you decorate a static method, the wrapper receives no self, which can cause an argument mismatch.
Another subtle issue is the order of multiple decorators. Decorators apply bottom-up, meaning the decorator closest to the method runs first. For example:
@decorator_a @decorator_b def method(): pass
method is first wrapped by decorator_b, then the result is passed to decorator_a. If the order matters, you must reason about which wrapper is outermost. This is especially relevant when decorators add behavior like caching or authentication.
Finally, if you need to access the original method from outside the wrapper, you can store a reference to it, but this is rarely necessary. In most cases, the wrapper is the public interface, and the original method is only accessible inside the closure.