Python Decorators

A decorator is a function that takes another function (or class) and returns a new function that usually wraps the original with extra behavior — logging, timing, caching, access control, and more — without changing the original function’s source code. Decorators are one of Python’s most distinctive features, built entirely from ordinary functions, closures, and the @ syntax sugar. Once you understand that a decorator is “just a function call in disguise,” the rest of Python’s more advanced tooling (Flask routes, @property, @staticmethod, functools.lru_cache) becomes far less mysterious.

Overview / How it works

In Python, functions are first-class objects: they can be assigned to variables, stored in data structures, passed as arguments, and returned from other functions. A decorator exploits this by being a higher-order function — a function that accepts a function as input and returns a function as output. The core pattern looks like this:

def decorator(func):
    def wrapper(*args, **kwargs):
        # code that runs before func
        result = func(*args, **kwargs)
        # code that runs after func
        return result
    return wrapper

When you write:

@decorator
def some_function():
    ...

Python does not do anything magical. It is exactly equivalent to writing:

def some_function():
    ...
some_function = decorator(some_function)

The name some_function now points to wrapper, not to the original function object. Every time you call some_function(), you are really calling wrapper(), which runs your extra logic and then calls the original function (which is captured by the closure over func). This is why decorators can run code before and after a call, inspect or modify arguments, suppress exceptions, cache return values, or even refuse to call the original function at all.

Because a decorator’s job is to accept one function and return one function, decorators compose naturally: you can stack several on top of one function, and you can build decorators that themselves take configuration arguments (a “decorator factory”). Understanding the object model — that @decorator is sugar for reassignment — is the key to reasoning about anything decorator-related, including tricky ordering questions.

Syntax

@decorator_name
def function_name(params):
    ...
Part Meaning
@decorator_name Applies decorator_name to the function defined immediately below it
decorator_name Any callable that accepts one function argument and returns a callable
wrapper(*args, **kwargs) The inner function that actually replaces the original; must accept whatever arguments the original function accepts
functools.wraps(func) A decorator (from the standard library) applied to wrapper that copies over __name__, __doc__, and other metadata from func

A decorator that itself needs configuration (for example, @repeat(times=3)) is written as a function that returns a decorator, which in turn returns a wrapper — three nested levels of functions in total.

Examples

Example 1: A basic logging decorator

import functools


def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}({args}, {kwargs})")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result!r}")
        return result
    return wrapper


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


add(3, 4)

Output:

Calling add((3, 4), {})
add returned 7

add is really wrapper now. When add(3, 4) runs, wrapper receives args=(3, 4) and kwargs={}, prints them, calls the real add, prints the result, and returns it. The caller never notices the extra machinery.

Example 2: A parameterized decorator (decorator factory)

import functools


def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            results = []
            for _ in range(times):
                results.append(func(*args, **kwargs))
            return results
        return wrapper
    return decorator


@repeat(times=3)
def greet(name):
    return f"Hello, {name}!"


print(greet("Ada"))

Output:

['Hello, Ada!', 'Hello, Ada!', 'Hello, Ada!']

repeat(times=3) is called first and returns decorator. That returned function is then applied to greet, exactly as in a plain decorator. The extra outer layer is only there to capture the configuration value (times) in a closure so wrapper can use it later.

Example 3: Stacking decorators

import functools


def count_calls(func):
    calls = 0

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        nonlocal calls
        calls += 1
        print(f"Call #{calls} to {func.__name__}")
        return func(*args, **kwargs)
    return wrapper


def memoize(func):
    cache = {}

    @functools.wraps(func)
    def wrapper(n):
        if n not in cache:
            cache[n] = func(n)
        return cache[n]
    return wrapper


@count_calls
@memoize
def square(n):
    return n * n


print(square(4))
print(square(4))
print(square(5))

Output:

Call #1 to square
16
Call #2 to square
16
Call #3 to square
25

Decorators stack bottom-up: the one closest to the function is applied first. Here square is wrapped by memoize first, then that result is wrapped by count_calls. So every call goes through count_calls‘s wrapper first (which counts and prints), then into memoize‘s wrapper (which checks the cache). Because functools.wraps propagated the original name through both layers, func.__name__ still reads "square" even two layers deep.

Under the hood, step by step

To see explicitly that @ is just reassignment, compare a decorated function to its manual equivalent:

def shout(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return wrapper


@shout
def greet():
    return "hello"


def greet_plain():
    return "hello"


greet_plain = shout(greet_plain)

print(greet())
print(greet_plain())

Output:

HELLO
HELLO

Both calls produce identical results because they are doing the identical thing: the name on the left of = (or the name below @shout) is bound to whatever shout() returns, which is wrapper, not the original function. The original greet/greet_plain function objects still exist — they are just captured inside wrapper‘s closure as func, reachable only through that closure. This is also why the wrapper needs *args, **kwargs: it doesn’t know in advance what signature the decorated function will have, so it must accept and forward anything.

Common Mistakes

Mistake 1: Forgetting functools.wraps

Without it, the wrapped function silently loses its identity — its __name__, __doc__, and other introspection metadata all become the wrapper’s, which breaks debugging, documentation tools, and anything relying on __name__.

def logger(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper


@logger
def process():
    """Process the data."""
    pass


print(process.__name__)
print(process.__doc__)

Output:

wrapper
None

The fix is to decorate the inner wrapper with @functools.wraps(func), which copies __name__, __doc__, and more from the original function onto wrapper:

import functools


def logger(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper


@logger
def process():
    """Process the data."""
    pass


print(process.__name__)
print(process.__doc__)

Output:

process
Process the data.

Mistake 2: A wrapper that doesn’t accept the original function’s arguments

If wrapper is written with a fixed (or empty) signature, the decorator only works for functions matching that exact signature, and breaks for anything else at call time:

def logger(func):
    def wrapper():
        print(f"Calling {func.__name__}")
        return func()
    return wrapper


@logger
def add(a, b):
    return a + b


print(add(3, 4))

Output:

TypeError: wrapper() takes 0 positional arguments but 2 were given

add is now wrapper, which takes no parameters, so calling add(3, 4) raises a TypeError before func is ever reached. The fix is to always give wrapper a generic *args, **kwargs signature and forward those straight through:

def logger(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper


@logger
def add(a, b):
    return a + b


print(add(3, 4))

Output:

Calling add
7

Best Practices

  • Always apply @functools.wraps(func) to your inner wrapper so metadata (__name__, __doc__, __module__) and introspection tools keep working.
  • Give wrapper the signature (*args, **kwargs) unless you have a specific reason to restrict it — this keeps your decorator general-purpose.
  • Keep decorators focused on one concern (logging, timing, caching, validation) — combine several small decorators via stacking rather than writing one that does everything.
  • Remember stacking order: decorators apply bottom-up (closest to the function first), so put the decorator that should run “innermost” directly above the function.
  • Prefer the standard library when it already solves your problem — functools.lru_cache for memoization, functools.singledispatch for type-based dispatch, contextlib.contextmanager for context-manager-producing generators.
  • If a decorator needs configuration arguments, structure it as a three-level decorator factory (def repeat(times): def decorator(func): def wrapper(...): ...) rather than trying to overload a single level.
  • Avoid decorators with hidden side effects that surprise callers (like silently swallowing exceptions) unless that is explicitly the decorator’s documented purpose.

Practice Exercises

  1. Write a decorator named uppercase that converts a wrapped function’s string return value to uppercase. Apply it to a function that returns "python is fun" and print the result. Expected output: PYTHON IS FUN.
  2. Write a parameterized decorator retry(times) that calls the wrapped function up to times times, stopping as soon as it succeeds without raising an exception, and re-raising the last exception if all attempts fail. Test it on a function that fails the first two times it’s called and succeeds the third time.
  3. Write two decorators, bold and italic, each wrapping a string-returning function’s output in <b> or <i> tags respectively. Stack them on a function returning "hello" so the output is <b><i>hello</i></b>, and explain in a comment why that particular stacking order produces that particular nesting.

Summary

  • A decorator is a callable that takes a function and returns a (usually new) function — @decorator above a def is exactly func = decorator(func).
  • The typical shape is decorator(func) returning an inner wrapper(*args, **kwargs) that calls func and adds behavior before/after.
  • Parameterized decorators need an extra outer layer: a factory function that accepts configuration and returns the actual decorator.
  • Stacked decorators apply bottom-up — the one nearest the function runs innermost.
  • functools.wraps preserves the original function’s name and docstring and should be used in virtually every decorator you write.
  • Common bugs come from omitting *args, **kwargs in wrapper, or forgetting to return the inner function’s result.