Decorators in Python: From Basics to Parameters
1. Overview
Decorator is one of the most “magical” features of Python but also easy to misunderstand if you just copy-paste without understanding the essence. This article goes from the concept of functions as first-class citizens, through basic decorators, decorators that preserve metadata with functools.wraps, to decorators that accept parameters and decorators written as classes. After reading, you’ll be able to write logging, timing, or retry decorators for your own projects without losing the original function name.
2. Functions as First-Class Citizens
Before learning decorators, you need to grasp one fact: in Python, functions are also objects. You can assign a function to a variable, pass a function as a parameter, or return a function from another function.
def greet(name: str) -> str:
return f"Hello, {name}!"
say_hello = greet
print(say_hello("Alice"))
Hello, Alice!
say_hello is just another name pointing to the same function object greet. Thanks to this characteristic, Python allows writing functions that take functions as input and return functions as output — that’s the foundation of decorators.
3. Basic Decorator: Function Wrapping Function
A decorator is simply a function that takes another function, returns a new function that “wraps” (modifies) the behavior of the original function.
def log_call(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
def add(a: int, b: int) -> int:
return a + b
add = log_call(add)
print(add(2, 3))
Calling add with args=(2, 3), kwargs={}
add returned 5
5
log_calltakesfunc(the originaladdfunction) and defineswrapper— an inner function that callsfuncback but with logging before and after.log_call(add)returnswrapper, and we assign it back toadd. From here on, callingadd(...)actually callswrapper(...).
3.1. The @ Syntax — Syntactic Sugar
Writing func = decorator(func) manually is quite verbose. Python provides the @decorator syntax to do exactly that, more concisely:
@log_call
def multiply(a: int, b: int) -> int:
return a * b
print(multiply(4, 5))
Calling multiply with args=(4, 5), kwargs={}
multiply returned 20
20
@log_call placed right above def multiply is equivalent to multiply = log_call(multiply). This is just syntactic sugar — there’s no hidden magic.
4. Losing Metadata and functools.wraps
When a decorator “replaces” the original function with wrapper, you lose attributes like __name__, __doc__ of the original function.
@log_call
def subtract(a: int, b: int) -> int:
"""Subtract two numbers."""
return a - b
print(subtract.__name__)
print(subtract.__doc__)
wrapper
None
This is a common bug when debugging or using documentation generation tools — you think you’re calling subtract but Python reports the name as wrapper. Fix it with functools.wraps:
import functools
def log_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def subtract(a: int, b: int) -> int:
"""Subtract two numbers."""
return a - b
print(subtract.__name__)
print(subtract.__doc__)
subtract
Subtract two numbers.
functools.wraps(func) is a decorator-of-a-decorator: it copies __name__, __doc__, __module__… from func to wrapper.
Note: Always use
functools.wrapsin real-world decorators. Skipping it will make debugging harder and break introspection (e.g.,help(), frameworks that rely on function names).
5. Decorators Accepting Parameters
Often you want a decorator to be customizable, like @retry(times=3). For this you need an extra layer of function: the outer function takes parameters and returns the actual decorator, and that decorator takes func.
import functools
import time
def retry(times: int = 3, delay: float = 1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_exception = exc
print(f"Attempt {attempt} failed: {exc}")
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@retry(times=2, delay=0)
def call_payment_api(order_id: int) -> str:
raise ConnectionError(f"Timeout for order {order_id}")
try:
call_payment_api(101)
except ConnectionError as exc:
print(f"Giving up: {exc}")
Attempt 1 failed: Timeout for order 101
Attempt 2 failed: Timeout for order 101
Giving up: Timeout for order 101
Remember the three-layer structure:
retry(times, delay)— factory function, takes configuration parameters, returnsdecorator.decorator(func)— the actual decorator function, takes the original function.wrapper(*args, **kwargs)— the execution function when calling the decorated function.
@retry(times=2, delay=0) actually calls retry(times=2, delay=0) first (returns decorator), then applies decorator to call_payment_api.
5.1. Decorator That Works Both With and Without Parameters
Sometimes you want @retry to work both with no parameters (@retry instead of @retry()) and with parameters. A clean approach is to use functools.partial with a callable check:
import functools
def retry(func=None, *, times: int = 3):
if func is None:
return functools.partial(retry, times=times)
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
print(f"Attempt {attempt} failed: {exc}")
raise RuntimeError("All attempts failed")
return wrapper
@retry
def task_a():
return "ok"
@retry(times=5)
def task_b():
return "ok"
When using @retry (without calling the function), func receives the task_a function directly, and the if func is None branch doesn’t execute. When using @retry(times=5), func is initially None, the function returns functools.partial(retry, times=5) — a function waiting to receive func in the next step, which is task_b.
6. Decorator Written as a Class
Instead of using nested functions, you can write decorators as classes by implementing __call__. This approach works well when the decorator needs to hold complex state.
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.call_count = 0
def __call__(self, *args, **kwargs):
self.call_count += 1
print(f"{self.func.__name__} called {self.call_count} time(s)")
return self.func(*args, **kwargs)
@CountCalls
def process_order(order_id: int) -> None:
print(f"Processing order {order_id}")
process_order(1)
process_order(2)
print(process_order.call_count)
process_order called 1 time(s)
Processing order 1
process_order called 2 time(s)
Processing order 2
2
__init__stores the original function and initializescall_count = 0.functools.update_wrapper(self, func)— equivalent tofunctools.wrapsbut for class instances, copies metadata to the objectself.__call__makes the instanceprocess_order(now aCountCallsobject, not a function anymore) callable like a regular function.
Note: Class-based decorators don’t work well when decorating methods in other classes (the
selfof the original class and the decorator can conflict). For methods, prefer nested functions orfunctools.singledispatchmethodif you need dispatch.
7. Stacking Multiple Decorators
You can stack multiple decorators on one function. The order of application is bottom-up (the decorator closest to the function runs first).
@log_call
@retry(times=2, delay=0)
def charge_card(amount: float) -> str:
if amount <= 0:
raise ValueError("Amount must be positive")
return f"Charged {amount}"
print(charge_card(10))
Calling wrapper
Charged 10
Here retry(times=2, delay=0) is applied first, wrapping charge_card into a function with retry capability; then log_call wraps it on the outside. If you don’t use functools.wraps correctly in retry, you’ll see the log print wrapper instead of charge_card — further evidence of the importance of section 4.
8. Writing Tests for Decorators
Decorators are still regular Python functions, so you test them with pytest like any other logic. Install the library if you don’t have it:
pip install pytest
Test example for the retry decorator:
import pytest
def test_retry_raises_after_exhausting_attempts():
call_log = []
@retry(times=3, delay=0)
def always_fail():
call_log.append("called")
raise ValueError("boom")
with pytest.raises(ValueError):
always_fail()
assert len(call_log) == 3
def test_retry_succeeds_on_second_attempt():
attempts = {"count": 0}
@retry(times=3, delay=0)
def fail_once_then_succeed():
attempts["count"] += 1
if attempts["count"] < 2:
raise ValueError("boom")
return "ok"
result = fail_once_then_succeed()
assert result == "ok"
assert attempts["count"] == 2
Run the tests:
pytest test_retry.py -v
test_retry.py::test_retry_raises_after_exhausting_attempts PASSED
test_retry.py::test_retry_succeeds_on_second_attempt PASSED
call_log and attempts act as spies to verify how many times the original function is called — matching the expected retry behavior, without needing to mock external libraries.
9. Common Mistakes
- Forgetting
functools.wraps: the function loses its name/docstring, making debugging harder and causing errors when used with frameworks that rely on__name__(e.g., routing in Flask). - Using mutable default arguments in wrapper: similar to common Python pitfalls, avoid having
args=[]as a default. - Decorator with heavy side-effects at import time: if the decorator performs I/O (API calls, file reads) immediately in
decorator(func)instead of inwrapper, it will run every time the module is imported, not every time the function is called. - Overusing decorators for complex logic: if a decorator contains too many branches, the code becomes harder to read than a direct function call. When logic is complex, consider context managers or separating into a separate class instead of stuffing everything into a decorator.
When NOT to use decorators: if the wrapping behavior is only used once, not reusable, writing a decorator just makes code harder to trace than calling the function directly. Decorators shine when applying cross-cutting concerns like logging, caching, retry, auth across many different functions.
10. Conclusion
Decorators in Python aren’t mysterious — they’re just functions that take functions and return functions, based on functions being first-class citizens. Starting from basic decorators, you add functools.wraps to preserve metadata, add an extra layer of function to accept configuration parameters, or switch to class-based when you need to maintain complex state. Mastering these patterns, you can write clean, testable logging, caching, retry, or validation decorators for your real-world projects.