Skip to content
elephantoo

Decorators

Lesson 25 of 38 14 min read

Wrap functions with @ syntax, functools.wraps, decorators with arguments, caching and registration.


A decorator is a function that takes another function and returns a modified version of it. With the @ syntax, you can add logging, timing, caching, access control or retries to any function without touching its code. Frameworks use decorators everywhere — @app.get("/") in FastAPI, @pytest.fixture, @dataclass, @property — so understanding them demystifies a lot of Python.

Decorators build on two ideas you already know: functions are objects (they can be passed and returned), and closures (inner functions remember variables from their enclosing scope).

A decorator by hand#

Python
def shout(func):
    def wrapper():
        result = func()
        return result.upper() + "!"
    return wrapper


def greet():
    return "hello"


greet = shout(greet)      # replace greet with the wrapped version
print(greet())
Output
HELLO!

shout receives the original function, defines a wrapper that calls it and modifies the result, and returns the wrapper. After greet = shout(greet), the name greet points to the wrapper — which still has access to the original through the closure variable func.

The @ syntax#

Writing greet = shout(greet) after every definition is clumsy, so Python offers syntactic sugar:

Python
def shout(func):
    def wrapper():
        return func().upper() + "!"
    return wrapper


@shout
def greet():
    return "hello"


print(greet())
Output
HELLO!

@shout above the def means exactly greet = shout(greet), applied immediately when the function is defined.

Handling any arguments#

The wrapper above only works for functions with no parameters. Use *args and **kwargs to accept and forward anything:

Python
import functools


def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        arg_text = ", ".join([repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()])
        print(f"calling {func.__name__}({arg_text})")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result!r}")
        return result
    return wrapper


@log_calls
def add(a, b):
    """Add two numbers."""
    return a + b


@log_calls
def greet(name, *, punctuation="!"):
    return f"Hi {name}{punctuation}"


add(2, 3)
greet("Ada", punctuation="?")
print(add.__name__, "-", add.__doc__)
Output
calling add(2, 3)
add returned 5
calling greet('Ada', punctuation='?')
greet returned 'Hi Ada?'
add - Add two numbers.

Always use functools.wraps

Without @functools.wraps(func), the decorated function would report its name as wrapper and lose its docstring — confusing in tracebacks, logs, help() and test reports. wraps copies __name__, __doc__, __module__ and more from the original, and stores the original as __wrapped__. Treat it as mandatory.

The standard decorator template#

Almost every decorator you write will look like this:

Python
import functools


def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # ... before ...
        result = func(*args, **kwargs)
        # ... after ...
        return result
    return wrapper

Practical example: timing#

Python
import functools
import time


def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            elapsed = time.perf_counter() - start
            print(f"{func.__name__} took {elapsed * 1000:.0f} ms")
    return wrapper


@timed
def slow_square(n):
    time.sleep(0.25)
    return n * n


print(slow_square(9))
Output
slow_square took 250 ms
81

Using try/finally means the timing is printed even if the function raises.

Decorators with arguments#

To write @retry(times=3), you need one more layer: retry(times=3) is called first, and it must return the actual decorator.

Python
import functools


def retry(times=3, exceptions=(Exception,)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    print(f"attempt {attempt} failed: {e}")
                    if attempt == times:
                        raise
        return wrapper
    return decorator


calls = 0


@retry(times=4, exceptions=(ConnectionError,))
def flaky_fetch():
    global calls
    calls += 1
    if calls < 3:                       # simulate two failures, then success
        raise ConnectionError("network blip")
    return "data"


print(flaky_fetch())
Output
attempt 1 failed: network blip
attempt 2 failed: network blip
data

Three levels: retry (takes configuration) → decorator (takes the function) → wrapper (takes the call's arguments). Real retry code would also wait between attempts (time.sleep, often with exponential back-off).

Decorators that keep state#

Because the wrapper is a closure, it can keep state across calls — or you can attach attributes to it:

Python
import functools


def count_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        return func(*args, **kwargs)
    wrapper.calls = 0
    return wrapper


@count_calls
def ping():
    return "pong"


for _ in range(3):
    ping()
print(ping.calls)
Output
3

Built-in and standard-library decorators#

You've already met several:

  • @property, @classmethod, @staticmethod — method types.
  • @dataclass — a class decorator that generates methods.
  • @functools.cache / @functools.lru_cache(maxsize=...) — memoisation.
  • @contextlib.contextmanager — turns a generator into a context manager.
  • @functools.total_ordering — fills in comparison methods.

Caching is a dramatic example — the naive recursive Fibonacci becomes instant:

Python
from functools import cache


@cache
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)


print(fib(100))
print(fib.cache_info().hits > 0)
Output
354224848179261915075
True

Without @cache, fib(100) would take longer than the age of the universe. Only cache pure functions (same input → same output, no side effects), and remember the cache holds every result in memory; use lru_cache(maxsize=1024) to bound it.

Stacking decorators#

Several decorators can be applied to one function. They're applied bottom-up (the one closest to def wraps first), and run top-down when called:

Python
import functools


def bold(func):
    @functools.wraps(func)
    def wrapper(*a, **k):
        return f"<b>{func(*a, **k)}</b>"
    return wrapper


def italic(func):
    @functools.wraps(func)
    def wrapper(*a, **k):
        return f"<i>{func(*a, **k)}</i>"
    return wrapper


@bold
@italic                 # same as: text = bold(italic(text))
def text(s):
    return s


print(text("hi"))
Output
<b><i>hi</i></b>

How frameworks use decorators#

A decorator doesn't have to wrap anything — it can just register the function and return it unchanged. That's how web frameworks map URLs to functions:

Python
routes = {}


def route(path):
    def decorator(func):
        routes[path] = func
        return func          # unchanged
    return decorator


@route("/")
def home():
    return "Welcome!"


@route("/about")
def about():
    return "About us"


for path in ["/about", "/", "/missing"]:
    handler = routes.get(path)
    print(path, "->", handler() if handler else "404 Not Found")
Output
/about -> About us
/ -> Welcome!
/missing -> 404 Not Found

When you see @app.get("/users") in FastAPI or @app.route("/") in Flask, this is essentially what's happening.

Common mistakes#

  • Forgetting to return the wrapper (or forgetting to return result inside it) — the decorated function becomes None or always returns None.
  • Calling the function in the decorator: return wrapper() instead of return wrapper.
  • Omitting functools.wraps.
  • Writing @retry when the decorator expects arguments — use @retry(), or design it to support both forms.
  • Caching impure functions with @cache — you'll get stale results.

What's next#

You've now seen most of Python's core language. Next, a guided tour of the standard library: os, pathlib, datetime, collections, itertools and more.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What is @my_decorator above def greet(): equivalent to?

  2. 2.Why should a decorator's wrapper use @functools.wraps(func)?

  3. 3.How many levels of nested functions does a decorator that takes arguments, like @retry(times=3), typically need?

Finished reading?

Mark this lesson complete to track your progress.