Python Decorator: How They Work and When to Use Them
python decorator: Learn how Python decorators work, how to write them, and when to use them for logging, timing, and access control without duplicating code.
A Python decorator is a function that takes another function and extends its behavior without explicitly modifying it. The @decorator syntax above a function definition applies the decorator at definition time. This article explains how decorators work, how to write them, and when they are the right tool for the job.
How a Decorator Works Under the Hood
A decorator is simply a callable that accepts a function and returns a callable, usually a wrapper that adds behavior before or after the original function runs. Consider the simplest example:
def my_decorator(func): def wrapper(*args, **kwargs): print("Before call") result = func(*args, **kwargs) print("After call") return result return wrapper @my_decorator def say_hello(): print("Hello!")
Using @my_decorator is equivalent to say_hello = my_decorator(say_hello). The name say_hello now points to the wrapper function. When you call say_hello(), it executes the wrapper, which prints a message, calls the original function, prints another message, and returns the result. This pattern is the foundation of all decorators.
The wrapper must accept *args and **kwargs to work with any function signature. It also must return the result of the original call, otherwise the caller receives None. If you forget the return statement, the decorated function silently returns nothing, which is a common source of bugs.
Writing a Decorator That Preserves Metadata
A plain wrapper replaces the original function's metadata, such as its name, docstring, and signature. This breaks tools that rely on introspection, like debuggers, documentation generators, and help(). The functools.wraps decorator copies the original function's metadata onto the wrapper:
import functools def my_decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): print("Before call") return func(*args, **kwargs) return wrapper
Now wrapper.__name__ and wrapper.__doc__ match the original function. Without functools.wraps, unit tests that inspect function names or documentation will fail. Always use it when writing a decorator that will be reused across a codebase.
Practical Example: Timing Functions
A common use for decorators is measuring execution time. Here is a timer that prints how long a function takes:
import functools import time def timer(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) end = time.perf_counter() print(f"{func.__name__} took {end - start:.6f} seconds") return result return wrapper @timer def compute(): return sum(range(1000000))
The timer wraps the original function, records the start time, calls the function, records the end time, and prints the difference. This is a clean way to add profiling to selected functions without scattering timing code throughout the application. However, the overhead of the wrapper itself is small but not zero; for extremely hot paths, the extra function call may matter, as discussed later.
Decorators with Arguments
Sometimes a decorator needs configuration. For example, you may want a retry decorator that accepts the number of attempts. This requires a decorator factory: a function that returns a decorator.
import functools import time def retry(max_attempts): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_attempts): try: return func(*args, **kwargs) except Exception as e: if attempt == max_attempts - 1: raise time.sleep(0.1) return wrapper return decorator @retry(max_attempts=3) def flaky_network_call(): # implementation pass
The outer function retry captures max_attempts and returns the actual decorator. The decorator then wraps the function with a loop that retries on exceptions. This pattern is useful when the same decorator needs different configurations in different places. Without the factory, you would need a global setting or a separate decorator for each configuration.
Common Mistakes and Pitfalls
Several mistakes appear frequently when developers first write decorators. The most common is forgetting to return the wrapper from the decorator. If you write:
def bad_decorator(func): def wrapper(*args, **kwargs): print("before") return func(*args, **kwargs) # missing return wrapper
The decorated name becomes None, and calling it raises TypeError. Always return the wrapper.
Another issue is using a decorator on a method without accounting for self. The wrapper must accept *args and **kwargs; the first positional argument will be the instance. This usually works fine, but if the decorator needs to access the instance, it must inspect args[0]. For class methods, the first argument is the class itself.
A third pitfall is overusing decorators for logic that is only needed in one place. If the extra behavior is specific to a single function, writing it inline may be clearer than introducing a new decorator. Decorators shine when the same behavior applies to many functions, but they add a layer of indirection that can obscure the function's actual logic.
Performance Considerations
Decorators add a function call for every invocation. In most applications, this overhead is negligible compared to I/O or other computation. However, if a decorated function is called millions of times in a tight loop, the extra call frame can become measurable. The impact depends on the decorator's implementation and the Python version. If performance is critical, you can reduce overhead by keeping the wrapper simple and avoiding unnecessary attribute lookups. For example, binding the original function to a local variable inside the wrapper can help slightly, but the dominant cost is the extra function call itself.
Another consideration is the cost of the decorator's setup. If the decorator performs expensive work at decoration time, that happens once when the module is imported. If the decorator performs expensive work on every call, that cost is repeated. For instance, a caching decorator that stores results in a dictionary avoids recomputation, but the dictionary lookup and insertion add overhead. Measure the actual impact rather than assuming decorators are always cheap or always expensive.
When to Use a Decorator vs Explicit Code
A decorator is the right choice when the same cross-cutting behavior applies to multiple functions and can be cleanly separated from the function's core responsibility. Examples include logging, authentication, input validation, and memoization. In these cases, a decorator keeps the function body focused on its primary task and avoids duplicated boilerplate.
Use explicit code when the behavior is only needed in one place, or when the logic depends on the function's internals in a way that would require awkward parameter passing. For example, if you need to log the value of a local variable inside the function, a decorator cannot access it without changing the function's signature. In such cases, inline logging is clearer.
Another decision factor is maintainability. A well-named decorator documents its effect: @requires_auth, @log_execution, @cache_result. But if the decorator's behavior is complex or poorly named, it can hide important details from the reader. Prefer small, focused decorators over ones that try to do many things.
Advanced: Class Decorators and Stacking
Decorators can also be applied to classes. A class decorator receives the class object and returns a new or modified class. This is useful for registering classes in a registry or adding methods dynamically.
def add_repr(cls): def __repr__(self): return f"{cls.__name__}({self.__dict__})" cls.__repr__ = __repr__ return cls @add_repr class Point: def __init__(self, x, y): self.x = x self.y = y
Decorators stack from bottom to top. The decorator closest to the function is applied first, then the one above it. For example:
@decorator_a @decorator_b def func(): pass
This is equivalent to func = decorator_a(decorator_b(func)). The order matters when decorators depend on each other's behavior. If decorator_a expects the function to have a certain attribute that decorator_b adds, the order must be correct. Understanding stacking helps debug unexpected behavior when multiple decorators are applied.