Decorators
The Problem: Adding Behaviour Without Repetition
Suppose you want to log every time certain functions are called — which function ran, and how long it took. You could add timing code inside every function. But that means repeating the same boilerplate everywhere, and cluttering the function's actual logic with instrumentation code.
A better question: can you add behaviour around a function without touching the function itself?
Yes. That's what decorators do.
Functions Wrapping Functions
You already know functions can receive other functions as arguments (from the intermediate lesson on advanced functions). The key step here is that a function can also return a function.
shout takes a function, defines a new wrapper function around it, and returns that wrapper. louder_greet is now the wrapped version of greet — it calls the original and shouts the result.
This is a decorator in its raw form: a function that wraps another function.
The @ Syntax
Python gives you cleaner syntax for this exact pattern:
@shout above the function definition is exactly equivalent to writing greet = shout(greet) after it. The @ syntax is pure convenience — it decorates the function at the point of definition.
A Practical Decorator: Timing
Two things to notice:
*args, **kwargs in the wrapper — the wrapper doesn't know what arguments the decorated function expects. By accepting and passing through *args, **kwargs, it works with any function signature. This is the standard pattern for writing general-purpose decorators.
func.__name__ — every function has a __name__ attribute holding its name as a string. Useful for logging.
Preserving the Original Function's Identity
Wrapping a function hides it. After decoration, the function's __name__ and docstring belong to wrapper, not the original:
greet.__name__ shows wrapper, not greet. The original identity is lost. For debugging and documentation, this is unhelpful.
functools.wraps fixes this cleanly:
@wraps(func) copies the original function's name, docstring, and other metadata onto the wrapper. Always use @wraps when writing decorators.
A Decorator With Arguments
What if you want to configure the decorator itself? You need one more layer — a function that receives the arguments and returns the decorator:
@repeat(times=3) calls repeat(3), which returns decorator, which then wraps say. Three layers: the outer function receives the configuration, the middle function receives the function to wrap, the inner function is the actual wrapper.
This pattern appears constantly in real frameworks — Flask routes, Django views, pytest fixtures all use configurable decorators.
Stacking Decorators
Multiple decorators can be applied to one function. They stack from bottom to top:
@shout is applied first (closest to the function), then @bold wraps the result. The order matters — try swapping them and observe the difference.
A Validation Decorator
Validation logic lives entirely in the decorator — calculate_total itself stays clean. Apply @require_positive to any function that needs it.
What You Have Learned
Decorators let you attach reusable behaviour to functions without modifying them — a clean, composable tool used throughout Python's ecosystem.
The key ideas:
- A decorator is a function that takes a function and returns a new (wrapped) function
@decoratoris syntactic sugar forfunc = decorator(func)- Always use
*args, **kwargsin the wrapper to handle any function signature - Always apply
@wraps(func)inside your decorator to preserve the original function's identity - Decorators with arguments require three nested layers: config → decorator → wrapper
- Multiple decorators stack bottom-to-top
In the next lesson, you'll learn about context managers — the machinery behind the with statement, and how to write your own.