The first time you see a Python decorator, it looks like magic. The function below has an @ symbol stuck on top of it, and somehow it now logs every call:

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

Magic is where bugs come from. So let’s pull this apart and see what’s actually going on. By the end of this post you’ll be able to read, write, and debug any decorator in any Python codebase.

The one-line definition

A decorator is just a function that takes a function and returns a function.

That’s the whole concept. The @ syntax is sugar.

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

is exactly equivalent to:

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

add = log_calls(add)

log_calls is a function. It takes add (a function) as input. It returns a new function (also called add) that wraps the original. Every time the rest of your code calls add(2, 3), it’s actually calling the wrapper.

That’s it. There is no magic.

A complete decorator from scratch

Let’s build log_calls:

def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}({args}, {kwargs})")
        result = func(*args, **kwargs)
        print(f"  → {result}")
        return result
    return wrapper


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


add(2, 3)
# Calling add((2, 3), {})
#   → 5

Three things to notice:

  1. log_calls takes a function (func) and returns a function (wrapper).
  2. The wrapper accepts *args, **kwargs so it works for any signature.
  3. Inside wrapper, we call the original func with whatever was passed in, and return its result.

This is the canonical decorator pattern. 95% of decorators look exactly like this.

Always use functools.wraps

There’s a subtle bug in the version above:

print(add.__name__)   # 'wrapper'  ← uh oh
print(add.__doc__)    # None       ← we lost the docstring

When you replace add with wrapper, you lose all the metadata. Help text, name, signature — gone. This breaks debuggers, IDEs, help(), and a lot of testing tools.

The fix is functools.wraps:

from functools import wraps


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

@wraps(func) copies __name__, __doc__, __module__, __qualname__, __annotations__, and a few other attributes from func onto wrapper. Always use it. I’ve never seen a decorator in production code that shouldn’t have it.

Decorators with arguments

What if you want to configure your decorator? Like @retry(times=3) instead of @retry?

The trick: you need another layer. A function that returns a decorator.

from functools import wraps
import time


def retry(times: int = 3, delay: float = 1.0):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == times:
                        raise
                    print(f"Attempt {attempt} failed: {e}. Retrying in {delay}s...")
                    time.sleep(delay)
        return wrapper
    return decorator


@retry(times=3, delay=0.5)
def fetch():
    response = httpx.get("https://flaky-api.example.com")
    response.raise_for_status()
    return response.json()

Walk through it:

  1. retry(times=3, delay=0.5) is called first. It returns decorator.
  2. @decorator is applied to fetch. It returns wrapper.
  3. fetch is now wrapper, which retries up to 3 times.

Three layers. Once you see this pattern, you’ll see it everywhere.

Decorators with state — use a class

Sometimes you want the decorator to remember things across calls. Class-based decorators are perfect for this:

from functools import wraps


class CallCounter:
    def __init__(self, func):
        wraps(func)(self)
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        return self.func(*args, **kwargs)


@CallCounter
def hello():
    print("hi")


hello()
hello()
hello()
print(hello.count)  # 3

The instance is the decorated function. Calling hello() invokes __call__, which bumps the counter and forwards to the real function.

This pattern is great when:

  • You need to attach state to the decorated function.
  • You want the decorator itself to expose methods (.reset(), .stats(), etc.).
  • The decorator logic is complex enough that a class is clearer than nested functions.

Decorating methods (gotcha)

When you decorate methods, don’t forget about self:

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


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

This works because *args happily catches self along with the other arguments. The wrapper doesn’t care. Just remember: args[0] will be the instance.

Stacking decorators

You can apply multiple decorators. They’re applied bottom-up:

@log_calls
@retry(times=3)
def fetch():
    ...

is equivalent to:

fetch = log_calls(retry(times=3)(fetch))

Reading top-to-bottom: the call passes through log_calls first, then retry, then the real function. Order matters! Logging the retries vs. logging the call are different.

Decorators in real codebases

Here are the patterns you’ll meet most often:

@property

Built-in. Turns a method into an attribute.

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return math.pi * self.radius ** 2

c = Circle(5)
c.area  # 78.539... — no parentheses!

@staticmethod / @classmethod

Built-in. Mark a method as not needing the instance / needing the class instead.

@functools.lru_cache

Memoize a pure function:

from functools import lru_cache

@lru_cache(maxsize=128)
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

Saves repeated work. But: only for pure functions (same input → same output, no side effects). If your function calls a database or talks to the network, lru_cache will return stale results.

@dataclass

Auto-generates __init__, __repr__, __eq__:

from dataclasses import dataclass

@dataclass
class User:
    id: int
    name: str
    email: str

Covered in detail in 10 Modern Python Tips That Will Quietly Make You Better .

Framework decorators

Django’s @login_required, FastAPI’s @app.get("/"), Flask’s @app.route("/") — all decorators. Same pattern as everything in this post.

A useful real-world decorator: timed

Here’s one you’ll actually want in your toolbox:

import time
from functools import wraps
from typing import Callable


def timed(label: str | None = None) -> Callable:
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            name = label or func.__name__
            start = time.perf_counter()
            try:
                return func(*args, **kwargs)
            finally:
                elapsed = (time.perf_counter() - start) * 1000
                print(f"[timed] {name}: {elapsed:.2f}ms")
        return wrapper
    return decorator


@timed("expensive query")
def fetch_users():
    ...

Drop it on any function and you get a per-call duration log. Great for tracking down slow code paths during development.

Common mistakes

  • Forgetting @wraps(func). Discussed above.
  • Wrapping a function but not returning the wrapper. A decorator that returns None will break the call site silently.
  • Calling the decorator instead of using @. @retry(times=3) works; @retry (without parens) calls retry(func) which is wrong if retry expects config arguments.
  • Catching exceptions you didn’t mean to swallow. A try/except: pass inside a wrapper can hide real bugs.
  • Doing expensive setup in the outer function. Code outside the wrapper runs once at decoration time. Code inside the wrapper runs on every call. Be deliberate about which is which.

Async decorators

Decorating async functions is the same pattern, just with async wrappers:

from functools import wraps
import asyncio


def async_timed(label: str | None = None):
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            name = label or func.__name__
            start = asyncio.get_event_loop().time()
            try:
                return await func(*args, **kwargs)
            finally:
                elapsed = (asyncio.get_event_loop().time() - start) * 1000
                print(f"[async-timed] {name}: {elapsed:.2f}ms")
        return wrapper
    return decorator


@async_timed("fetch user")
async def fetch_user(user_id: int):
    ...

The wrapper is async def, and it awaits the wrapped function. Otherwise, identical pattern.

If you’re new to async/await, see A Practical Guide to Python Async/Await .

Conclusion

Decorators stop being magic the moment you see them as “functions that return functions.” Once you’ve internalized the pattern — outer wrapper, inner function, @wraps(func), optionally a third layer for arguments — you can read, write, and debug any decorator in any Python codebase.

The next time someone says “wow, that’s clever” about a decorator, you’ll know it’s just three nested functions with a clean syntax. That’s the best kind of clever.

Happy decorating!


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .