Skip to content
FollowtheDamnCode

Decorators

Wrapping functions to add behaviour.

5 minute read

Big idea

A decorator wraps a function in another function to add behaviour — logging, timing, checking — without changing the original's code. @name above a def applies it.

Functions are values

Two facts make decorators possible. A function can be passed around like any value, and a function can be defined inside another and returned:

A function that makes functions
def make_multiplier(k):
    def multiply(x):
        return x * k
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5), triple(5))
output
10 15

Run and edit it in the app.

multiply remembers the k it was made with, even after make_multiplier has returned. That's called a closure.

A decorator by hand

A decorator is a function that takes a function and returns a new, wrapped one:

Wrapping a function
def shout(func):
    def wrapper(name):
        result = func(name)
        return result.upper() + "!"
    return wrapper

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

greet = shout(greet)
print(greet("ada"))
output
HELLO, ADA!

Run and edit it in the app.

greet = shout(greet) replaces greet with the wrapper, which calls the original inside it.

The @ syntax

@shout above a def is exactly the same as writing greet = shout(greet) after it:

Predict: a logging decorator
def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}{args}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

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

total = add(2, 3)
print(total * 10)
What does it print? Show the answer
calling add(2, 3)
add returned 5
50

Run and edit it in the app.

*args, **kwargs let the wrapper accept whatever arguments the original takes and pass them straight through — so one decorator works on any function.

A call goes through the wrapper

Remember to return

Fix the bug: the decorated function should still return its result
def announce(func):
    def wrapper(*args):
        print("Running", func.__name__)
        func(*args)
    return wrapper

@announce
def square(n):
    return n * n

print(square(6))
output
Running square
36

Run and edit it in the app.

The wrapper calls func but throws away its result, so square(6) returns None. Write return func(*args).

Decorators you'll see

You'll use more decorators than you write:

  • @dataclass — from the OOP module.
  • @functools.cache — remembers results so repeated calls are instant.
  • @property, @staticmethod, @classmethod — inside classes.
  • Web frameworks use them for routes: @app.get("/").

When you write your own, add @functools.wraps(func) above def wrapper so the wrapped function keeps its name and docstring.

@timer above def run(): ... is the same as…

Watch it

Think of it like

Gift wrapping. The present inside is unchanged, but the wrapping adds something on the outside — a bow, a tag. And you can wrap any present the same way.

Key terms

  • Decorator — A function that takes a function and returns a wrapped version.
  • Closure — An inner function that remembers variables from where it was made.
  • @ syntax — Applies a decorator to the function defined below it.

Check yourself

What does a decorator receive?

Why does a wrapper usually take *args, **kwargs?

A wrapper calls func(x) but doesn't return it. What does the decorated function return?