Lesson 22 of 25

Decorators

The Two Ideas Underneath

Decorators look like magic until you separate them into the two ordinary features they are built from. Neither is new; you have met both already.

The first is that functions are values. You can pass a function to another function and you can return one. Lesson 14 used this for key= and map(); here it is used in both directions at once — a function takes a function and hands back a function.

The second is that a function can be defined inside another function, and the inner one can see the outer one's variables even after the outer call has finished. That combination — an inner function plus the variables it captured — is called a closure. It is what lets a wrapper remember which function it is wrapping.

Put them together and you have a decorator: a function that takes a function, defines a new function around it, and returns that. The new function usually does something before the call, something after, or both, and passes the arguments through untouched with *args and **kwargs.

The purpose is to add behaviour without editing the function itself. Timing, logging, caching, retrying, checking permissions — these are concerns that apply to many functions and belong to none of them. Writing them once as a decorator keeps each function about its own job.

Example
# Idea 1: functions are values
def shout(text):
    return text.upper()

say = shout                 # no parentheses — the function itself
print(say("hello"))         # HELLO

def apply_twice(func, value):
    return func(func(value))

print(apply_twice(shout, "hi"))    # HI


# Idea 2: a function defined inside another, remembering its variables
def make_multiplier(factor):
    def multiply(value):
        return value * factor    # 'factor' is captured from outside
    return multiply              # returned, not called

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10), triple(10))    # 20 30

# The captured value is still there after make_multiplier has returned
print(double.__closure__[0].cell_contents)   # 2


# Both ideas together: a function that wraps a function
def announce(func):
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} finished")
        return result
    return wrapper

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

add = announce(add)          # this is all a decorator does
print(add(3, 5))
# calling add
# add finished
# 8
Notes
  • If the wrapper needs to change a captured variable rather than only read it, declare it nonlocal. Without that, assigning inside the wrapper creates a new local variable — the same rule that makes global necessary at module level.

What the @ Symbol Actually Does

The last line of the previous example was add = announce(add). The @ syntax is a shorthand for exactly that, and nothing more. Writing @announce above def add(...) means: define the function, pass it to announce, and bind the result to the name add.

Two consequences follow. First, the decorator runs at definition time, not when the function is called — so it runs when the module is imported. A decorator that prints something, opens a connection or registers the function does that work once, at import, which is exactly how web frameworks build their route tables.

Second, the name now refers to the wrapper. Every later call goes through it. That is the whole mechanism, and it explains why a decorator has to return something callable: if you forget the return wrapper line, the name is bound to None and the next call fails with TypeError: 'NoneType' object is not callable.

The wrapper should take *args, **kwargs and pass them straight through, so the decorator works on functions with any signature. It should also return the result of the inner call — forgetting that is the other classic mistake, and it turns every decorated function into one that quietly returns None.

The same syntax works on methods inside a class. Because self is just the first positional argument, a wrapper that collects *args receives it along with everything else and passes it on without needing to know it exists.

Example
import time
from functools import wraps

def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)      # pass everything through
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result                       # and return what it gave back
    return wrapper                          # the decorator returns the wrapper

@timer
def slow_sum(n):
    return sum(range(n))

print(slow_sum(1_000_000))
# slow_sum took 0.0234s
# 499999500000


# @ is only shorthand — these two are identical
@timer
def a(): pass

def b(): pass
b = timer(b)


# The decorator runs at DEFINITION time
registry = {}

def command(func):
    print(f"registering {func.__name__}")   # prints on import
    registry[func.__name__] = func
    return func                             # unchanged; just recorded

@command
def add_task(): return "added"

@command
def list_tasks(): return "listed"

print(registry.keys())          # dict_keys(['add_task', 'list_tasks'])
print(registry["add_task"]())   # added


# Two mistakes that bite
def broken(func):
    def wrapper(*args, **kwargs):
        func(*args, **kwargs)      # result not returned -> always None
    # return wrapper               # missing -> name becomes None


# Works on methods too: self arrives inside *args
class Report:
    @timer
    def build(self, rows):
        return sum(rows)

print(Report().build([1, 2, 3]))    # 6
  • @d above def f means f = d(f) — nothing else
  • The decorator runs when the function is defined, i.e. at import time
  • The wrapper must be returned, or the name becomes None
  • The wrapper must return the inner call's result, or the function returns None
  • *args, **kwargs in the wrapper make it work with any signature
  • Methods work the same way — self is just the first positional argument
Notes
  • Use time.perf_counter() for measuring durations rather than time.time(). It is designed for intervals and is not affected by the system clock being adjusted while your code runs.

functools.wraps: Keeping the Function's Identity

A decorator replaces your function with the wrapper, and the wrapper is a different object with a different name. So after decorating, add.__name__ is "wrapper", the docstring is gone, and help(add) shows the wrapper's signature instead of the real one.

That is more than cosmetic. Tracebacks name wrapper instead of the function that failed, which makes debugging harder in exactly the situation where you need help. Documentation tools generate nonsense. And anything that inspects functions at runtime — test frameworks collecting tests, web frameworks matching routes, serialisers reading type hints — sees the wrong object.

functools.wraps fixes it in one line. Applied as a decorator to the wrapper, it copies the original's __name__, __doc__, __module__, __qualname__ and annotations across, so the wrapper presents itself as the function it replaced. It also sets __wrapped__, which points at the original if you ever need to reach past the decoration.

Treat it as compulsory. Every decorator you write should have @wraps(func) on its wrapper — there is no case where leaving it out is an improvement, and it costs one import and one line.

Example
from functools import wraps

def plain(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

def proper(func):
    @wraps(func)                      # copy the identity across
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper


@plain
def greet_a(name):
    """Return a greeting."""
    return f"Hello, {name}"

@proper
def greet_b(name):
    """Return a greeting."""
    return f"Hello, {name}"


print(greet_a.__name__)   # wrapper        <- wrong
print(greet_a.__doc__)    # None           <- lost

print(greet_b.__name__)   # greet_b        <- correct
print(greet_b.__doc__)    # Return a greeting.

# And the original is still reachable
print(greet_b.__wrapped__("Asha"))   # Hello, Asha — bypasses the wrapper

# Why it matters beyond looks
import inspect
print(inspect.signature(greet_a))    # (*args, **kwargs)
print(inspect.signature(greet_b))    # (name)
Notes
  • If a decorated function's traceback mentions wrapper and you cannot tell which function actually failed, a missing @wraps is the reason. Adding it makes the next traceback name the real function.

Decorators That Take Arguments

@repeat(3) looks like a decorator with an argument, and understanding what it really is makes the three-level nesting obvious. @ always expects something that takes a function. repeat(3) is a call, so what follows the @ is that call's result, and that result has to be a decorator.

So repeat is not a decorator at all — it is a decorator factory. It takes the configuration, builds a decorator that has captured it, and returns it. The full expansion is say_hello = repeat(3)(say_hello), which is why there are three levels: the factory takes the settings, the decorator takes the function, and the wrapper takes the call's arguments.

The usual mistake is forgetting the parentheses. Writing @repeat instead of @repeat(3) passes your function in as the times argument, so the decorator receives no function and the error appears somewhere baffling. If a decorator that takes arguments fails strangely, check the brackets first.

This pattern is everywhere in real frameworks — @app.route("/students"), @pytest.mark.parametrize(...), @lru_cache(maxsize=128) — because configuration per decorated function is the normal case. Some libraries support both forms with and without parentheses, which takes extra work in the decorator; when writing your own, pick one and require it.

Example
from functools import wraps

def repeat(times):                       # 1. takes the configuration
    def decorator(func):                 # 2. takes the function
        @wraps(func)
        def wrapper(*args, **kwargs):    # 3. takes the call's arguments
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def say_hello(name):
    print(f"Hello, {name}")

say_hello("Asha")
# Hello, Asha  (three times)

# The full expansion
def greet(name): print(f"Hi, {name}")
greet = repeat(2)(greet)
greet("Ravi")


# Forgetting the brackets
# @repeat                 # 'times' is now the function itself
# def broken(): pass      # and the failure appears far from here


# A practical one: retry a flaky operation
import time, random

def retry(attempts=3, delay=0.5):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == attempts:
                        raise                    # out of tries — let it go
                    print(f"attempt {attempt} failed ({e}); retrying")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(attempts=3, delay=0.1)
def fetch_marks():
    if random.random() < 0.7:
        raise ConnectionError("network down")
    return {"Asha": 87}

print(fetch_marks())
Notes
  • A retry decorator should re-raise on the final attempt rather than returning None. Silently giving up looks like success to the caller, which is the exact failure mode the error-handling lesson warned about.

Stacking Decorators, and the Order Rule

Several decorators can be applied to one function, and the order is not arbitrary. They are applied bottom-up: the one nearest the def wraps the original function first, and each one above wraps the result of the one below.

So @a above @b above def f means f = a(b(f)). At call time this reverses: a's wrapper is the outermost, so its code runs first, then b's, then the real function — and the returns unwind in the opposite order. Applied bottom-up, executed top-down.

That matters whenever the decorators are not independent. A timer above a cache measures cache hits, while a timer below it measures only real work. A logger above an authentication check records attempts including rejected ones; below it, only authorised calls appear. Neither is wrong, but they answer different questions, so decide which one you meant.

Two or three is a comfortable stack. Past that, the function's actual behaviour becomes hard to predict from reading it, and every layer adds a frame to every traceback. If a function needs five decorators, some of that behaviour probably belongs inside it or in the caller.

Example
from functools import wraps

def exclaim(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs) + "!"
    return wrapper

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

@exclaim
@shout
def greet(name):
    return f"hello {name}"

print(greet("asha"))       # HELLO ASHA!
# shout wraps greet first, then exclaim wraps that

@shout
@exclaim
def greet2(name):
    return f"hello {name}"

print(greet2("asha"))      # HELLO ASHA!  — same here, but not in general


# Where the order genuinely changes the answer
def trace(label):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            print(f"-> {label}")
            result = func(*args, **kwargs)
            print(f"<- {label}")
            return result
        return wrapper
    return decorator

@trace("outer")
@trace("inner")
def work():
    print("   working")

work()
# -> outer
# -> inner
#    working
# <- inner
# <- outer

# Applied bottom-up; executed top-down
from functools import lru_cache

# timer OUTSIDE cache: measures cache hits too (fast after the first call)
# timer INSIDE cache:  measures only the real computation
  • Decorators are applied bottom-up: nearest the def goes on first
  • @a over @b over def f means f = a(b(f))
  • At call time the outermost runs first — top-down
  • Order changes behaviour when the decorators interact (cache, timing, logging, auth)
  • Two or three is plenty; each layer adds a traceback frame
Notes
  • When debugging a heavily decorated function, func.__wrapped__ gets you one layer inwards, and inspect.unwrap(func) follows the chain all the way to the original — provided every decorator used @wraps.

The Decorators You Will Actually Use

Most of the decorators in your code will be ones you did not write. You have already met several: @property, @staticmethod, @classmethod and @dataclass from the OOP lessons, and @abstractmethod from the abstraction lesson. Recognising the pattern is what lets you read them.

@functools.lru_cache is the one that earns the most surprise per line. It remembers what a function returned for each set of arguments and hands back the stored answer next time. On a naive recursive Fibonacci it turns an exponential computation into an instant one, because each value is calculated once instead of thousands of times. @functools.cache, from Python 3.9, is the same thing with an unlimited size.

It has one requirement that catches people: the arguments must be hashable, because they are used as dictionary keys. Passing a list raises TypeError: unhashable type: 'list'. Convert to a tuple, or cache a level higher up. It also requires the function to be pure — if it reads a file or a clock, cached answers go stale and the bug is very hard to see.

In frameworks, decorators are how you attach your code to the system. @app.route("/students") in Flask registers a function as a URL handler. @pytest.fixture marks a function as reusable test setup. In every case the mechanism is the one from this lesson: a function taking your function, doing something with it at import time, and returning something.

For your own code, the test for whether a decorator is the right tool is whether the behaviour is orthogonal to what the function does. Timing, logging, caching, retrying, permission checks — these apply to many functions and belong to none. Business logic does not; putting it in a decorator hides it from anyone reading the function.

Example
from functools import lru_cache, cache

# Without caching, this recomputes the same values endlessly
def fib_slow(n):
    return n if n < 2 else fib_slow(n - 1) + fib_slow(n - 2)

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

print(fib(50))            # 12586269025 — instant
print(fib.cache_info())   # hits, misses, maxsize, currsize
fib.cache_clear()

@cache                    # Python 3.9+, same as lru_cache(maxsize=None)
def slow_lookup(roll):
    return roll.upper()


# Arguments must be hashable
@cache
def total(values):
    return sum(values)

print(total((1, 2, 3)))   # 6 — a tuple is fine
# print(total([1, 2, 3])) # TypeError: unhashable type: 'list'


# Decorators you have already used
class Student:
    pass_mark = 40                       # class attribute

    def __init__(self, name, marks):
        self.name, self.marks = name, marks

    @property
    def average(self):
        return sum(self.marks) / len(self.marks)

    @classmethod
    def from_csv(cls, line):
        name, *rest = line.split(",")
        return cls(name, [int(r) for r in rest])

    @staticmethod
    def is_valid(mark):
        return 0 <= mark <= 100

s = Student.from_csv("Asha,78,84,91")
print(s.name, round(s.average, 2), Student.is_valid(105))

# Framework style — the same mechanism
# @app.route("/students")        # Flask: register a URL handler
# @pytest.fixture                # pytest: reusable test setup
# @dataclass                     # generate __init__, __repr__, __eq__
  • @property, @classmethod, @staticmethod — from the OOP lessons
  • @dataclass, @abstractmethod — generate boilerplate, enforce a contract
  • @lru_cache / @cache — remember results; arguments must be hashable
  • @cached_property — compute once per instance, then remember
  • @wraps — on every decorator you write, without exception
  • Use a decorator for concerns orthogonal to the function, never for its actual logic
Notes
  • lru_cache keeps a reference to every argument it has seen, so caching a method holds each self alive for the life of the program. For per-instance caching use functools.cached_property, which stores the value on the object itself.
Ask AI