Decorators

Wrap a function with another function — log, time, cache, or check inputs without changing the function itself.

python·~25 minutes·Lesson 10 of 14
Read and write the `@decorator` syntaxDefine a decorator as a function that returns a wrapped functionUse `*args, **kwargs` so a decorator works on any function shape

Suppose you have a dozen functions and you want every one of them to log when it gets called. Option one: edit each function and add a print at the top and bottom. That's twelve places to keep in sync, and twelve places to undo when you don't need the logging anymore.

Option two: write the wrapping logic once, in a function that takes another function and returns a new one with the extra behavior baked in. Then mark each function you want wrapped with @that_decorator. That's a decorator. Most "cross-cutting" concerns — logging, timing, caching, retries, auth checks — fit this shape.

What you'll learn

  • Read and write the @decorator syntax
  • Define a decorator as a function that returns a wrapped function
  • Use args, *kwargs so the decorator works for any function shape

Instructions

Write a decorator log_calls(func) that wraps any function and makes it print:

  • calling <name> before the call,
  • the wrapped function's return value, then
  • returned <value> after the call.

The wrapper must accept any arguments — use args, *kwargs — and return whatever func returns.

Apply it with @log_calls to two functions:

  • add(a, b) returns a + b
  • greet(name) returns f"hello, {name}"

Then in the script:

add(2, 3)
greet("world")

Your output must match exactly:

calling add
returned 5
calling greet
returned hello, world

Key concepts

Functions are values

Before the syntax, the load-bearing fact: in Python, a function is just a value. You can pass one to another function, return it, store it in a variable. That's why a decorator can take a function as its argument and return a new function — there's nothing magical about it.

def shout(msg):
    return msg.upper()

f = shout         # not calling, just renaming
f("hi")           # "HI"

A decorator is a function that returns a function

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

log_calls takes a function func and gives you back wrapper, a different function that wraps a call to func with two prints. The inner wrapper references func — that's a closure. It's the same trick the practice exercises lean on for state and caching. The closure is what lets the wrapper "remember" which function it's wrapping.

The @ syntax

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

is exactly the same as:

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

The @decorator line is sugar — Python calls the decorator on the function and reassigns the name. Everywhere add is referenced afterwards, it's actually the wrapper.

args and *kwargs

The wrapper has no idea what arguments the wrapped function takes. Maybe it takes none, maybe it takes ten, maybe a mix of positional and keyword. args, *kwargs is the catchall:

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

In the function definition, args collects extra positional args into a tuple and kwargs collects extra keyword args into a dict. In the call, the same syntax unpacks* them back. So wrapper(1, 2, name="alice") becomes func(1, 2, name="alice") no matter what func actually accepts.

(You don't have to call them args and kwargs — those are just convention. Python only cares about the and *.)

When NOT to use a decorator

Decorators have real costs. They make stack traces less obvious — every call goes through wrapper first, which clutters tracebacks. They mask the wrapped function's signature in tooling (you can fix this with functools.wraps, but only if you remember). And they hide work, which makes one-place behavior look like everywhere-behavior to a reader.

A working rule: reach for a decorator when the same wrapping logic applies to several functions. Don't dress up a single one-off call site as a decorator just because you can — a regular wrapper call is clearer when there's only one client.

Hints

(These are surfaced by the tutor on request — they don't auto-reveal.)

  1. The log_calls decorator is a function that takes func and returns wrapper. wrapper does three things: print before, call func and store the result, print after, then return the result.
  2. Shape:
def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"returned {result}")
        return result
    return wrapper

Then put @log_calls on the line above each def add(...) and def greet(...). After that, add(2, 3) triggers all three lines automatically.

  1. The same shape applied to timing a function instead of logging — different problem, identical mechanic:
import time

def timed(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed_ms = (time.perf_counter() - start) * 1000
        print(f"{func.__name__} took {elapsed_ms:.2f} ms")
        return result
    return wrapper