Chapter 6 · Senior Developer

Decorators

In Python, functions are objects — you can pass them around, store them in variables, and return them from other functions. A function that takes or returns a function is a higher-order function, and a decorator is exactly that: a function that takes a function, wraps it in extra behaviour, and gives back the wrapped version.

The @decorator line above a def is just sugar. @shout over def greet(): ... means greet = shout(greet) — the name now points at the wrapper, which calls the original inside itself. That lets you bolt on timing, logging, retries, or caching across a whole codebase without editing each function.

Always wrap the inner function with functools.wraps(fn). Without it, the wrapper steals the original's identity — greet.__name__ becomes "wrapper" and the docstring vanishes, which breaks debugging and documentation tools. wraps copies the metadata across so the decorated function still looks like itself.

For a decorator that takes arguments (like @retry(3)) you need one more layer: an outer factory that takes the args and returns the actual decorator. So it's three nested functions deep — factory → decorator → wrapper. A classic use is memoization: cache a function's results by its arguments so repeat calls are instant.

Syntax

import functools

def my_decorator(fn):
    @functools.wraps(fn)              # preserve fn's name/docstring
    def wrapper(*args, **kwargs):
        # ... before ...
        result = fn(*args, **kwargs)
        # ... after ...
        return result
    return wrapper

@my_decorator                        # same as: greet = my_decorator(greet)
def greet(name): ...

# Decorator WITH arguments — one extra layer:
def retry(times):
    def decorator(fn): ...
    return decorator

Worked examples

A wrapping decorator, with functools.wraps keeping identity

import functools

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

@shout
def greet(name):
    "Return a greeting."
    return f"hi {name}"

print(greet("sam"))      # HI SAM
print(greet.__name__)    # greet  (preserved by wraps)
# Output:
# HI SAM
# greet

Memoize — cache results by arguments

import functools

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

calls = []
@memoize
def square(n):
    calls.append(n)        # records each REAL computation
    return n * n

print(square(5), square(5))   # 25 25
print("real calls:", len(calls))   # 1 — second was cached
# Output:
# 25 25
# real calls: 1

A decorator with an argument (retry factory)

import functools

def retry(times):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            for attempt in range(times):
                try:
                    return fn(*args, **kwargs)
                except Exception:
                    if attempt == times - 1:
                        raise          # give up on the last try
        return wrapper
    return decorator

log = []
@retry(3)
def flaky():
    log.append(1)
    if len(log) < 3:
        raise ValueError("not yet")
    return "ok"

print(flaky())       # ok
print(len(log))      # 3  (failed twice, succeeded on the third)
# Output:
# ok
# 3

Common mistakes & gotchas

Forgetting functools.wraps

Without @functools.wraps(fn) on the inner wrapper, the decorated function reports __name__ as "wrapper" and loses its docstring. Tracebacks, help(), and documentation generators all get confused. It's a one-line habit — always add it.

A decorator-with-args needs THREE layers

@retry(3) first *calls* retry(3), which must return a decorator, which then receives the function. Miss a layer and you get TypeError: decorator() takes 1 positional argument but .... The shape is factory → decorator → wrapper; the factory returns decorator, not wrapper.

Mutable default caches are shared

Defining the cache = {} inside the decorator (one per decorated function) is correct. Putting a mutable cache as a *default argument* (def wrapper(args, cache={})) creates one shared dict across unrelated calls — a classic mutable-default-argument bug. Keep state in the closure, as the memoize example does.

Why it matters

Decorators are everywhere in real frameworks — `@app.route` in Flask, `@pytest.fixture`, `@property`, `@functools.lru_cache`. They're the mechanism for cross-cutting concerns: the stuff (auth, timing, caching, logging) you want around many functions without copy-pasting it into each one. Reading and writing them is a core senior-level skill.