Wrapping a Function Without Rewriting It
Adding logging to ten functions usually means editing ten functions. Write the logging once as a decorator, apply it with one line above each function, and leave the original code untouched.
Read first: Naming a Piece of Work
Adding logging to ten functions usually means editing ten functions. Write the logging once as a decorator, apply it with one line above each function, and leave the original code untouched.
A function is a value like any other
Everything a decorator does rests on one fact that is easy to forget once you are used to calling functions with parentheses: a function is a value, the same as a number or a string, and a name can point at it without calling it.
$ >>> def shout(text):$ ... return text.upper()$ ...$ >>> greeting = shout$ >>> greeting("hello")$ 'HELLO'$ >>> functions = [shout, len, print]greeting = shout does not call shout — there are no parentheses — it makes a second name point at the exact same function object, the same way two variables can point at the same list. functions = [shout, len, print] works for the identical reason: a list can hold functions just as easily as it holds numbers, because to Python they are both just values.
A decorator is built entirely out of this one fact, applied twice: a function that takes a function in as an argument, and a function that returns a function as its result.
A function that takes a function and returns one
$ >>> def log_calls(func):$ ... def wrapper(*args):$ ... print(f"calling {func.__name__}")$ ... return func(*args)$ ... return wrapper$ ...$ >>> def add(a, b):$ ... return a + b$ ...$ >>> add = log_calls(add)$ >>> add(2, 3)$ calling add$ 5log_calls takes a function in and returns a different function — wrapper — that does the logging, then calls the original underneath. Reassigning add = log_calls(add) replaces the name add with the wrapped version; the original function still exists, just without a name pointing at it anymore.
The part that makes this work is a closure: even after log_calls has finished running and returned, wrapper still remembers which func was passed in. That is not something you arranged by hand — a nested function automatically keeps hold of the variables from the function that defined it, for as long as the nested function itself still exists. Without a closure, wrapper would have no way to know which function it was supposed to be wrapping by the time you actually called it.
@ is not special syntax, it is one line saved
$ >>> @log_calls$ ... def add(a, b):$ ... return a + b$ ...$ >>> add(2, 3)$ calling add$ 5@log_calls written above def add(...) does exactly what add = log_calls(add) did on the previous line — Python runs it automatically, immediately after the function is defined.
Stack more than one decorator and Python applies them from the bottom up, closest to the function first: @a above @b above def f(): ... is a(b(f)), not b(a(f)). Get the order backwards and the code still runs, which is exactly what makes the mistake easy to miss — a logging decorator applied outside a timing decorator logs before the clock starts rather than after, and nothing about the syntax warns you.
What a decorator costs you when it goes wrong
Every decorated function now runs through wrapper first, which makes errors inside it harder to trace — a traceback that should point at add often points at wrapper instead, one layer removed from where the real work happens.
add.__name__ reports 'wrapper', not 'add' — the decorator quietly replaced the function's identity along with its behaviour. Adding @functools.wraps(func) above the inner def wrapper copies the original name and docstring back onto it, fixing this without changing what the decorator does.
This is the one line every real decorator in the standard library and popular packages includes, and the one line most hand-written ones forget.
A decorator that takes its own arguments
@log_calls has no way to be configured — every function it wraps gets the exact same behaviour. A decorator that needs its own argument, like @repeat(3) to call a function three times, needs one more layer of nesting than log_calls had.
$ >>> def repeat(times):$ ... def decorator(func):$ ... def wrapper(*args):$ ... result = None$ ... for _ in range(times):$ ... result = func(*args)$ ... return result$ ... return wrapper$ ... return decorator$ ...$ >>> @repeat(3)$ ... def greet(name):$ ... print(f"Hello, {name}")$ ...$ >>> greet("Ada")$ Hello, Ada$ Hello, Ada$ Hello, Ada- 1
Python evaluates repeat(3) first
This runs immediately, on its own, and returns decorator — with times fixed at 3 inside its closure.
- 2
The returned decorator is applied to greet
Exactly like log_calls was applied to add — decorator(greet) runs next.
- 3
decorator(greet) returns wrapper
wrapper now closes over two things: func (greet) and times (3), both remembered from the layers above it.
- 4
The name greet is reassigned to wrapper
Identical to the plain decorator case — just built on the fly, with times baked in for this particular use.
The three layers are easy to lose track of, so it helps to name them by what each one takes: repeat takes the decorator's own argument, decorator takes the function being wrapped, and wrapper takes the arguments of an actual call. A decorator with arguments still needs functools.wraps(func) on its innermost wrapper — the extra layer changes nothing about that.
Key takeaways
- A function is a value like any other — a name can point at one without calling it, which is what makes wrapping a function possible at all.
- A decorator is a function that takes a function and returns a replacement — usually one that wraps the original with extra behaviour.
- @decorator_name above a def is shorthand for func = decorator_name(func), run automatically right after the function is defined.
- A decorated function runs through the wrapper first, which is why tracebacks from decorated code can look one layer removed from the real error.
- functools.wraps(func) on the inner wrapper preserves the original function's name and docstring, which a bare wrapper silently loses — a decorator with its own arguments still needs it.
Quick check
Answer these to unlock the next chapter — 3 of 4 to pass. You can retake it anytime.
Answer every question to check.
Make a free account to read on
Every chapter is free — an account is how your progress, XP, and streak follow you from your laptop to your phone, and how you show up on the leaderboard. No payment, no trial.