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_call takes func (the original add function) and defines wrapper — an inner function that calls func back but with logging before and after.
  • log_call(add) returns wrapper, and we assign it back to add. From here on, calling add(...) actually calls wrapper(...).

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.wraps in 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:

  1. retry(times, delay) — factory function, takes configuration parameters, returns decorator.
  2. decorator(func) — the actual decorator function, takes the original function.
  3. 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 initializes call_count = 0.
  • functools.update_wrapper(self, func) — equivalent to functools.wraps but for class instances, copies metadata to the object self.
  • __call__ makes the instance process_order (now a CountCalls object, not a function anymore) callable like a regular function.

Note: Class-based decorators don’t work well when decorating methods in other classes (the self of the original class and the decorator can conflict). For methods, prefer nested functions or functools.singledispatchmethod if 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 in wrapper, 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.

pythondecoratorclosurefunctional-programmingclean-code