Python Decorators Explained with Practical Examples

A decorator wraps a function to add behavior such as timing, retries, or caching. Learn how they work, how to write them correctly, and when to avoid them.

Executive Summary: A Python decorator takes a function and returns a replacement, letting you add timing, retries, caching, or access checks without editing the original. This guide builds decorators from first principles, explains why to use functools.wraps and keep business logic out, and ends with a runnable retry and timing example.

A team adds the same six lines to forty functions: record the start time, run the work, record the end time, and log the difference. Then the log format changes, and someone edits forty functions. One of them gets missed. A decorator would have put those six lines in one place, applied with a single line above each function.

Python decorators are callables that accept a function and return a function, usually a wrapper that adds behavior before or after the original runs. The @name line above a definition is shorthand for passing the function through that callable.

This guide uses Python 3.10 and only the standard library. Check your interpreter with python --version. You need two ideas first. A closure is an inner function that remembers variables from the function that created it. The *args, **kwargs pair collects and forwards any arguments. You can read more about closures and the *args and **kwargs syntax if either is unfamiliar.

My position: a good decorator is one you could delete without changing what the function returns. Logging, timing, caching, and retries pass that test. Anything that alters results or hides control flow belongs in the function body, where readers can see it.

How Python decorators work

Functions in Python are objects. You can assign one to a variable, pass it as an argument, and return it from another function. A decorator uses all three abilities.

def shout(func):
    def wrapper():
        result = func()
        return result.upper()
    return wrapper


def greet():
    return 'hello'


greet = shout(greet)      # replace greet with the wrapper
print(greet())
HELLO

The shout function receives greet, defines a wrapper that calls it, and returns the wrapper. The wrapper is a closure: it remembers func after shout has finished. The @ syntax performs the same reassignment for you:

@shout
def greet():
    return 'hello'
What @shout does

  @shout                      greet = shout(greet)
  def greet(): ...     ==

What a call does afterwards

  greet()  --->  wrapper()  --->  original greet()
                    |                   |
                    +---- result <------+
                    |
                 returns result.upper()

Nothing else is involved. A decorator is ordinary function application with nicer syntax. The trade-off appears immediately, though: the name greet now points at wrapper, not at the code you wrote.

Forward any arguments and return the result

The first wrapper accepted no arguments, so it works only for functions without parameters. A general decorator forwards whatever it receives and returns whatever the function returns.

def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f'calling {func.__name__} with {args} {kwargs}')
        result = func(*args, **kwargs)
        print(f'{func.__name__} returned {result!r}')
        return result
    return wrapper


@log_calls
def add(a, b=0):
    return a + b


add(2, b=3)
calling add with (2,) {'b': 3}
add returned 5

Two bugs are common here. Forgetting return result makes every decorated function return None. Forgetting *args, **kwargs raises TypeError: wrapper() takes 0 positional arguments but 1 was given.

Preserve identity with functools.wraps

Because the wrapper replaces the original, the function’s name and docstring disappear unless you copy them.

# WRONG: the decorated function loses its name and docstring
def log_calls(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper


@log_calls
def add(a, b):
    """Return the sum of a and b."""
    return a + b


print(add.__name__, add.__doc__)
wrapper None
# RIGHT: functools.wraps copies the metadata onto the wrapper
import functools


def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper


@log_calls
def add(a, b):
    """Return the sum of a and b."""
    return a + b


print(add.__name__, add.__doc__)
add Return the sum of a and b.

Without wraps, every traceback, log line, and profiler row shows wrapper. With forty decorated functions, you cannot tell which one failed. functools.wraps also sets a __wrapped__ attribute that points at the original, which helps debugging and testing. It costs one line, so apply it to every wrapper you write.

Decorators with arguments

Sometimes the decorator itself needs configuration, as in @retry(times=3). That requires one more layer. The outer function takes the settings and returns a decorator, which takes the function and returns the wrapper.

retry(times=3)          returns   decorator
decorator(func)         returns   wrapper
wrapper(*args, **kw)    calls     func, up to 3 times

The complete example below shows the full pattern. One pitfall follows from the extra layer. If you write @retry without parentheses, Python passes your function as the times argument. No error appears at definition time. Instead, calling the function returns another function, not your result. Always include the parentheses for a decorator that takes arguments.

A complete example: timing and retries

This script defines a timing decorator and a configurable retry decorator, then stacks them. It also uses ParamSpec, added to typing in Python 3.10, so type checkers keep the decorated function’s real signature. Save it as decorators_demo.py.

import functools
import time
from typing import Callable, ParamSpec, TypeVar

P = ParamSpec('P')
R = TypeVar('R')


def timed(func: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            elapsed = time.perf_counter() - start
            print(f'{func.__name__} took {elapsed:.4f}s')
    return wrapper


def retry(
    times: int = 3,
    exceptions: tuple[type[Exception], ...] = (Exception,),
    delay: float = 0.0,
):
    def decorator(func: Callable[P, R]) -> Callable[P, R]:
        @functools.wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as error:
                    if attempt == times:
                        raise
                    print(f'attempt {attempt} failed: {error}')
                    time.sleep(delay)
            raise AssertionError('times must be at least 1')
        return wrapper
    return decorator


calls = {'count': 0}


@timed
@retry(times=3, exceptions=(ConnectionError,))
def fetch_report(name: str) -> str:
    """Pretend to call a server that fails twice, then succeeds."""
    calls['count'] += 1
    if calls['count'] < 3:
        raise ConnectionError('server busy')
    return f'report {name} ready'


def main() -> None:
    print(fetch_report('sales'))
    print(fetch_report.__name__)


if __name__ == '__main__':
    main()
python decorators_demo.py
attempt 1 failed: server busy
attempt 2 failed: server busy
fetch_report took 0.0000s
report sales ready
fetch_report

Three design choices matter. First, retry catches only the exception types you list, so a bug such as TypeError fails at once instead of being retried. Second, the final attempt uses a bare raise, so the caller sees the real error with its traceback. Third, timed prints in a finally block, so it reports the duration even when the function raises.

Stacking order

When you stack decorators, Python applies them from the bottom up. The line closest to the function wraps first.

@timed                              fetch_report = timed(retry(...)(fetch_report))
@retry(times=3)
def fetch_report(): ...

call  --->  timed wrapper  --->  retry wrapper  --->  original function

Here, timed is outermost, so it measures all three attempts together. Swap the two lines and it would time each attempt separately, printing three durations. Consequently, the order of decorators is part of the behavior, and a reordering during cleanup can change results.

The myth: the decorator runs each time you call the function

Many developers picture a decorator as code that runs on every call. Only the wrapper does. The decorator function itself runs exactly once, when Python executes the def statement, which usually means at import time.

def announce(func):
    print(f'decorating {func.__name__}')
    return func


@announce
def hello():
    print('hello')


print('module loaded')
hello()
hello()
decorating hello
module loaded
hello
hello

The message decorating hello prints once, before the module finishes loading, and never again. Notice also that announce returns the function unchanged. A decorator does not have to wrap anything.

This timing leads to an insight that the wrapping examples hide. A decorator is a hook that runs at definition time, which makes it a registration tool. Web frameworks use this for routes, and test tools use it for marks:

COMMANDS = {}


def command(func):
    COMMANDS[func.__name__] = func
    return func


@command
def backup():
    print('backing up')


@command
def restore():
    print('restoring')


COMMANDS['backup']()
print(sorted(COMMANDS))
backing up
['backup', 'restore']

The same timing carries a cost. Any slow work in the decorator body, such as opening a connection or reading a file, runs on import. It slows startup and runs even in code paths that never call the function.

Decorators in the standard library

You already use decorators that ship with Python. Knowing them prevents you from rewriting them.

Decorator What it does Watch out for
@functools.lru_cache and @functools.cache Remember results for repeated arguments Arguments must be hashable, and the cache keeps them alive
@property Exposes a method as a read-only attribute Callers expect it to be cheap
@staticmethod and @classmethod Change what a method receives as its first argument A plain function is often simpler than a static method
@contextlib.contextmanager Turns a generator function into a context manager Wrap the yield in try and finally
@dataclasses.dataclass Generates __init__, __repr__, and __eq__ for a class It decorates a class, not a function

The last row shows that decorators apply to classes too. A class decorator receives the class and returns a class, which is exactly how @dataclass works. You can read more about dataclasses and regular classes separately.

Caching deserves one specific warning. Never put lru_cache on a generator function. The cache stores the generator object, and the second caller receives the same, already exhausted generator and gets no items. If that sentence is unclear, read about how generators work.

import functools


@functools.lru_cache(maxsize=None)
def fibonacci(n: int) -> int:
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)


print(fibonacci(80))
print(fibonacci.cache_info())
23416728348467685
CacheInfo(hits=78, misses=81, maxsize=None, currsize=81)

Without the cache, this recursive function would make an astronomical number of calls for n=80. With it, each value is computed once.

What a wrapper costs

Every decorated call adds one extra function call, plus the packing and unpacking of arguments. You can measure it with my illustrative script:

import functools
import timeit


def passthrough(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper


def plain(x):
    return x


@passthrough
def wrapped(x):
    return x


if __name__ == '__main__':
    number = 1_000_000
    print(f'plain:   {timeit.timeit(lambda: plain(1), number=number):.3f}s')
    print(f'wrapped: {timeit.timeit(lambda: wrapped(1), number=number):.3f}s')

On my machine, the wrapped call takes noticeably longer than the plain one for this trivial function, and the absolute difference stays well under a microsecond per call. For a function that queries a database, that overhead is invisible. For a function called ten million times in a tight loop, it can dominate. Measure before you decorate a hot path.

How real systems use decorators

  • Route and command registration. Web frameworks such as Flask map a URL to a function with @app.route('/tasks'). The decorator records the function in a table at import time.
  • Authentication and permission checks. A decorator such as Django’s @login_required rejects the request before the view runs. The view body then contains only the feature logic.
  • Retries around network calls. Client code wraps calls to other services with a retry decorator that lists specific exception types and a backoff delay.
  • Caching pure functions. Expensive, deterministic lookups carry @lru_cache with a bounded maxsize.
  • Metrics and tracing. A timing decorator reports durations to a metrics system, so every decorated function gets the same measurement without repeated code.

A mistake I have seen in production is @lru_cache placed on an instance method of a request-scoped object. The cache held a reference to every instance through its self argument, so none of them could be garbage-collected. Memory grew steadily until the process restarted each night. Moving the cached logic into a module-level function that took only hashable values fixed the leak.

Writing a decorator: a decision framework

Ask these questions in order before you write one.

  1. Does the behavior apply to several unrelated functions? If only one function needs it, put the code in that function.
  2. Could you remove it without changing return values? If yes, it is a cross-cutting concern and a good fit. If no, make it an explicit function call.
  3. Does the standard library already provide it? Check functools and contextlib first.
  4. Does it need configuration? Write a decorator factory with keyword arguments, and require the parentheses.
  5. Does it only need to record the function? Return the original function unchanged and skip the wrapper entirely.

When NOT to use a decorator

  • When it changes what the function means. A decorator that converts return values, swallows exceptions, or skips the call under some condition hides control flow. Readers of the function body cannot see it.
  • When a context manager fits better. If you need the behavior around a few lines, not a whole function, a with block is more precise.
  • When the stack grows past two or three. Five stacked decorators create an ordering puzzle. At that point, one explicit function that calls the steps in sequence is easier to test.

Common mistakes

  • Forgetting to return the result. The wrapper calls the function and drops the value. Every decorated function returns None.
  • Skipping functools.wraps. Names and docstrings become wrapper and None. Tracebacks and logs stop identifying the function.
  • Omitting parentheses on a decorator factory. Writing @retry instead of @retry() passes the function as a setting. Calls then return a function instead of a result.
  • Retrying every exception. A decorator that catches Exception retries programming errors too. Bugs take longer to surface and can repeat side effects such as sending emails.
  • Doing heavy work at decoration time. Code in the decorator body runs on import. Startup slows down, and imports can fail when a service is unreachable.
  • Caching functions with mutable or unhashable arguments. lru_cache raises TypeError: unhashable type: 'list', or returns a shared mutable result that one caller then modifies for everyone.

Key takeaways

  • @decorator above a function means func = decorator(func), and nothing more.
  • A wrapper should accept *args, **kwargs, call the original, and return its result.
  • Apply @functools.wraps(func) to every wrapper.
  • A decorator with arguments needs three levels: settings, function, call.
  • Stacked decorators apply bottom-up, so the top one runs first at call time.
  • The decorator body runs once at definition time, and only the wrapper runs per call.
  • Keep decorators for logging, timing, caching, retries, and access checks.

FAQ

What is a decorator in Python?

A decorator is a callable that takes a function and returns a function, usually a wrapper that adds behavior. The @name syntax applies it at definition time.

What does functools.wraps do?

It copies the original function’s name, docstring, and other metadata onto the wrapper. Without it, the decorated function reports itself as wrapper in tracebacks and logs.

How do I pass arguments to a Python decorator?

Write a function that accepts the arguments and returns a decorator. You then apply it with parentheses, for example @retry(times=3).

In what order are multiple decorators applied?

Python applies them from the bottom up. The decorator closest to the function wraps it first, and the top decorator becomes the outermost layer that runs first on each call.

Do decorators slow down Python code?

Each wrapper adds one extra function call, which costs well under a microsecond. That matters only for very cheap functions called millions of times.

Wrap the concern, not the logic

Decorators remove repetition by moving a shared concern into one place. They work well when that concern is independent of what the function computes. Once a decorator starts deciding results, you have hidden a function call behind an @ sign.

Rule of thumb: if deleting the decorator would change the answer, it should not be a decorator.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *