Skip to content
C Codeloom
Python

Python Decorators Deep Dive

A practical tour of Python decorators: how they work under the hood, when to use them, and how to write decorators that preserve metadata, accept arguments, and stack cleanly.

·5 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • How decorators desugar into plain function calls
  • Writing decorators with and without arguments
  • Preserving metadata with functools.wraps
  • Stacking decorators and order of execution
  • Common pitfalls and production-grade patterns

Prerequisites

  • Basic Python

What and why

A decorator is a callable that takes a function and returns a function. That is all. The @ syntax is sugar: @deco above def f(): ... means f = deco(f). Once you internalize that, decorators stop feeling magical and start feeling like ordinary higher-order functions.

Why use them? Decorators give you a clean place to insert cross-cutting behavior: logging, caching, authentication, retries, timing, validation. Instead of repeating the same five lines at the top of every view or job, you wrap the function and move on.

Mental model

Think of a decorator as a pipeline. The original function goes in, a new function comes out, and that new function is what your caller actually invokes. The original is captured in a closure.

@log_calls               log_calls(greet)
def greet(name):    =>   greet = log_calls(greet)
  ...

call greet("ada")
   |
   v
+----------------+    +-----------------+    +----------------+
|  wrapper(*a)   | -> | original greet  | -> |  return value  |
| pre-hook       |    | (closure)       |    | post-hook      |
+----------------+    +-----------------+    +----------------+
Decorator desugaring

Hands-on example

Start with the simplest possible decorator: one that logs every call.

import functools
import time

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

@log_calls
def greet(name):
    return f"hello, {name}"

greet("ada")

functools.wraps copies the name, docstring, and __wrapped__ attribute from the original to the wrapper. Without it, greet.__name__ becomes "wrapper", which breaks introspection, Sphinx docs, and frameworks like FastAPI that read signatures.

Now a decorator that takes arguments. This requires one more layer: a factory that returns the decorator.

def retry(times=3, delay=0.1):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last = None
            for attempt in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    last = exc
                    time.sleep(delay * (2 ** attempt))
            raise last
        return wrapper
    return decorator

@retry(times=4, delay=0.2)
def fetch(url):
    ...

Read it bottom-up: retry(times=4, delay=0.2) runs first and returns decorator. Then decorator(fetch) runs and returns wrapper. The @ syntax glues these calls together.

Stacking works the same way. The decorator closest to the function is applied first.

@log_calls
@retry(times=3)
def charge(user, amount):
    ...
# equivalent: charge = log_calls(retry(times=3)(charge))

So when you call charge(...), log_calls’s wrapper runs first, which calls retry’s wrapper, which calls the real charge. The outermost decorator sees every retry as a single call. Swap the order and log_calls would log each retry attempt instead.

Common pitfalls

Forgetting functools.wraps is the most common mistake. It bites you the moment you look at stack traces or generate OpenAPI schemas.

Mutating shared state in the closure is another trap. If your decorator keeps a list of seen arguments for caching, that list is global to all calls of the wrapped function. Use functools.lru_cache for memoization instead of hand-rolling unless you really need custom eviction.

Decorating methods is fragile. self is just the first positional argument from the wrapper’s perspective, so it works, but if you want a decorator that distinguishes methods from functions, inspect the signature or use functools.wraps plus inspect.ismethod carefully. For classmethods and staticmethods, apply your decorator below @classmethod, never above.

Finally, async functions need async wrappers. A sync wrapper around an async function returns a coroutine without awaiting it, which silently breaks behavior.

def timed(func):
    @functools.wraps(func)
    async def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return await func(*args, **kwargs)
        finally:
            print(f"{func.__name__} took {time.perf_counter()-start:.3f}s")
    return wrapper

If you need one decorator that supports both, branch on asyncio.iscoroutinefunction(func) and return the appropriate wrapper.

Production tips

Make the wrapped function discoverable. Set wrapper.__wrapped__ = func (which functools.wraps does automatically) so that inspect.unwrap can peel layers in production debugging tools.

Keep decorators thin. If logic grows past twenty lines, push it into a helper class and have the decorator delegate. This makes the behavior testable in isolation and avoids deeply nested closures.

Be careful with side effects at import time. Code inside the decorator body, but outside wrapper, runs once when the module is imported. That is often what you want for registering routes or metrics, but it also means a slow import-time call can hurt cold starts.

Type your decorators. Use typing.ParamSpec and TypeVar so that the wrapped function keeps its original signature in your IDE and in mypy.

from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")

def log_calls(func: Callable[P, R]) -> Callable[P, R]:
    ...

Wrap-up

Decorators are just functions that return functions. The @ syntax is sugar, functools.wraps is mandatory, and decorators with arguments need one extra layer. Once you have the mental model, you can compose retries, logging, auth, caching, and timing without polluting every function body. Keep them small, type them, and respect async boundaries.