datarekha

Decorators

Wrap any function with logging, retry, caching, or auth — without touching its code. The @ syntax, demystified.

10 min read Advanced Python Lesson 15 of 41

What you'll learn

  • Functions are objects — pass them, return them, store them
  • The @ syntax, and what it literally expands to
  • Why you need functools.wraps, and what breaks without it
  • Parameterized decorators, stacking order, and real-world uses

Before you start

A decorator sounds advanced and is, at heart, almost embarrassingly simple: it is a function that takes a function and returns a new function. That is the whole definition. The @ symbol is just a tidy way to apply one. Once that clicks, a long list of things that looked like separate magic — @retry, @app.get("/users"), @property, @dataclass — turn out to be the very same trick, and you can read framework code that used to look opaque.

Functions are objects

The idea decorators rest on is that a function in Python is an ordinary object: you can assign it to a variable, drop it in a list, pass it to another function, and return it from one. Let us establish that plainly before any @ appears:

def greet(name):
    return f"Hello, {name}!"

# Assign a function to another name.
say = greet
print(say("Aarav"))

# Put functions in a list and call each.
funcs = [greet, str.upper, len]
for f in funcs:
    print(f("Priya"))

# Take a function in, return a new function out.
def shout_wrapper(fn):
    def wrapper(name):
        return fn(name).upper()
    return wrapper

loud_greet = shout_wrapper(greet)
print(loud_greet("Diego"))
Hello, Aarav!
Hello, Priya!
PRIYA
5
HELLO, DIEGO!

Look at shout_wrapper: it accepts a function and returns a new function that calls the original and then upper-cases the result. That is already a decorator — a higher-order function, one that takes or returns another function. Everything that follows is just nicer syntax for applying it.

The @ syntax

Here is the same shout_wrapper, now applied with @:

def shout(fn):
    def wrapper(name):
        return fn(name).upper()
    return wrapper

@shout
def greet(name):
    return f"Hello, {name}!"

# @shout above greet is EXACTLY: greet = shout(greet)

print(greet("Sofia"))
HELLO, SOFIA!

That is the one equivalence to memorise: @shout written above a function is identical to greet = shout(greet) written below it. It is pure sugar — but it puts the intent right at the definition, where a reader will see it.

A first real decorator — logging every call

The pattern that makes decorators general is *args, **kwargs. By forwarding whatever arguments arrive, one decorator works on any function. Here is a call-logger that announces what goes in and what comes out:

import functools

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

@logged
def square_sum(n):
    return sum(i * i for i in range(n))

@logged
def make_user(user_id, name):
    return {"id": user_id, "name": name}

square_sum(5)
make_user(42, "Aarav")
  -> square_sum(5,)
  <- square_sum returned 30
  -> make_user(42, 'Aarav')
  <- make_user returned {'id': 42, 'name': 'Aarav'}

Because wrapper(*args, **kwargs) accepts and forwards anything, the same @logged decorated both a one-argument and a two-argument function without changing a line. Swap the body for a timer, a retry loop, or an auth check and you have logging, resilience, and security — all without touching the functions themselves. That separation is the entire appeal.

The functools.wraps detail

You may have noticed @functools.wraps(fn) in that wrapper, and it is not optional decoration. Without it, the decorated function forgets its own identity — its name, docstring, and signature all become the wrapper’s — and that quietly breaks debuggers, IDE help, and tools like FastAPI that build their schemas by introspection:

import functools

def bad(fn):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

def good(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

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

print("WITHOUT wraps:")
print("  name:", add.__name__)
print("  doc :", add.__doc__)

@good
def subtract(a, b):
    """Subtract b from a."""
    return a - b

print("WITH wraps:")
print("  name:", subtract.__name__)
print("  doc :", subtract.__doc__)
WITHOUT wraps:
  name: wrapper
  doc : None
WITH wraps:
  name: subtract
  doc : Subtract b from a.

The contrast is stark: without @wraps, add reports its name as wrapper and loses its docstring entirely; with it, subtract keeps both.

Parameterized decorators — the three-layer pattern

What about a decorator that takes its own arguments, like @retry(times=3)? That needs three nested functions: the outer accepts the arguments, the middle accepts the function, and the inner does the work at call time. To keep the demo exact rather than random, our flaky call is rigged to fail twice and then succeed:

import functools, time

def retry(times=3, delay=0.05):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            last_err = None
            for attempt in range(1, times + 1):
                try:
                    return fn(*args, **kwargs)
                except Exception as e:
                    last_err = e
                    print(f"  attempt {attempt} failed: {e}")
                    time.sleep(delay)
            raise last_err
        return wrapper
    return decorator

# A call that fails its first two attempts, then succeeds on the third.
state = {"calls": 0}

@retry(times=4, delay=0.05)
def flaky_api_call():
    state["calls"] += 1
    if state["calls"] < 3:
        raise ConnectionError("network blip")
    return {"data": "ok"}

print("result:", flaky_api_call())
  attempt 1 failed: network blip
  attempt 2 failed: network blip
result: {'data': 'ok'}

The mental model is the key: @retry(times=4) first calls retry(times=4), which returns decorator, which is then applied to the function. So retry is not itself the decorator — it is a decorator factory, and the extra layer exists precisely to capture the arguments.

A caching decorator

Caching is the classic decorator, and writing one shows how little there is to it — a dictionary keyed on the arguments:

import functools

def memo(fn):
    cache = {}
    @functools.wraps(fn)
    def wrapper(*args):
        if args not in cache:
            cache[args] = fn(*args)
        return cache[args]
    return wrapper

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

print(fib(50))
12586269025

That fib(50) returns instantly. Without the cache it would fan out into roughly a billion redundant recursive calls; with it, each n is computed once and remembered. In real code you would not hand-roll this — the standard library’s functools.lru_cache (and functools.cache, from Python 3.9) does it better — but understanding the mechanism is exactly what lets you read how those work.

Stacking decorators

import functools

def loud(fn):
    @functools.wraps(fn)
    def w(*a, **k):
        print(f"  calling {fn.__name__}")
        return fn(*a, **k)
    return w

def upper_result(fn):
    @functools.wraps(fn)
    def w(*a, **k):
        return fn(*a, **k).upper()
    return w

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

print(greet("aarav"))
  calling greet
HELLO, AARAV

Stacking applies bottom-up at definition time — the decorator closest to def wraps first — so greet becomes loud(upper_result(greet)). At call time the layers run outside-in: loud’s wrapper runs first (printing the line), then hands off to upper_result’s wrapper (which upper-cases the result). The picture below is the way to hold it in your head — a decorated function is an onion, and a call passes inward through each layer and the result unwinds back out:

call inresult out@loud@upper_resultgreet()greet = loud(upper_result(greet)) — the call enters @loud first, reaches greet, then unwinds out.

In one breath

  • A decorator is a function that takes a function and returns a new one; @d means f = d(f).
  • Make a wrapper general with *args, **kwargs so it forwards any call.
  • Always add @functools.wraps(fn), or the wrapped function loses its name, docstring, and signature.
  • A decorator that takes arguments needs three layers — it is a decorator factory.
  • Stacked decorators apply bottom-up; a call runs them outside-in, like an onion.

Practice

Quick check

0/3
Q1`@decorator` above a function is equivalent to:
Q2Why use functools.wraps in your decorator?
Q3Why do parameterized decorators need three nested functions?

What’s next

Decorators wrap functions. Context managers wrap blocks of code — the same idea of “do something before and after,” with a different syntax built around with.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

FAQCommon questions

Questions about this lesson

What is a Python decorator, in simple terms?

A decorator is a function that wraps another function to add behavior before or after it runs, without changing the original function's code. You apply it with the `@decorator` syntax above a definition. Common uses are logging, timing, caching, and access control.

How does the @ decorator syntax actually work?

`@my_decorator` above a function is shorthand for `func = my_decorator(func)` — the decorator receives the function as an argument and returns a (usually wrapped) replacement. That returned function is what the name now points to.

Why use functools.wraps in a decorator?

Wrapping a function normally hides its name, docstring, and signature behind the wrapper. `@functools.wraps(func)` copies that metadata onto the wrapper so debuggers, tools, and `help()` still show the original function's identity.

Practice this in an interview

All questions

Related lessons

Explore further

Skip to content