Python Decorators Explained: A Step-by-Step Guide
Imagine you have a gift. You wrap it in paper, add a ribbon, attach a card. The gift inside is completely unchanged — but now it has extra features. The paper protects it, the ribbon makes it look nice, the card adds a message.
That's what decorators do to functions. They wrap a function with extra behavior — logging, timing, caching, access control — without touching the function's original code. It's one of the most powerful patterns in Python.
Why Can Functions Be Passed Around Like Variables?
Before we dive into decorators, you need to understand one key fact: in Python, functions are objects. You can assign them to variables, put them in lists, and pass them as arguments to other functions.
Notice the difference: greet (no parentheses) is the function object itself. greet('Alice') (with parentheses) calls the function. This distinction is critical for decorators.
How Do Closures Enable Decorators?
A closure is a function that remembers variables from its enclosing scope, even after that scope has finished executing. This is the mechanism that makes decorators work.
The inner function multiply captures factor from its parent. Even after make_multiplier finishes, the inner function still has access to factor. Decorators use this exact pattern to "remember" the original function.
What Is the Basic Decorator Pattern?
A decorator is a function that takes a function as input, creates a wrapper function that adds behavior, and returns the wrapper. Here's the template:
When Python sees @my_decorator above a function, it's equivalent to writing say_hello = my_decorator(say_hello). The @ syntax is just a cleaner way to express this.
What does this code print? Pay attention to the order of execution.
def shout(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
@shout
def greet(name):
return f'hello, {name}'
print(greet('world'))Write one print() statement that produces the exact same output.
Why Do You Need functools.wraps?
There's a subtle problem with basic decorators. When you replace a function with a wrapper, the wrapper has a different name and docstring. This makes debugging confusing.
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def add(a, b):
"""Add two numbers."""
return a + b
print(add.__name__) # wrapper (!)from functools import wraps
def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def add(a, b):
"""Add two numbers."""
return a + b
print(add.__name__) # addWrite a decorator called log_call that prints the function name and arguments before calling the function, then prints the return value after.
Use functools.wraps. Expected behavior:
@log_call
def add(a, b):
return a + b
result = add(3, 4)Should print:
Calling add(3, 4)
add returned 7How Do You Write a Decorator That Takes Arguments?
Sometimes you want to configure a decorator. For example, a @repeat(3) decorator that calls a function 3 times. This requires an extra layer of nesting — a function that returns a decorator.
The trick is three levels of nesting: the outermost function (repeat) takes the decorator arguments, returns a decorator function, which takes the function, which returns the wrapper. It's a function that returns a function that returns a function.
Write a decorator repeat(n) that calls the decorated function n times and collects all return values into a list.
@repeat(3)
def roll_dice():
return 6
print(roll_dice())
# Output: [6, 6, 6]What Happens When You Stack Multiple Decorators?
You can apply multiple decorators to a single function. They're applied bottom-up (closest to the function first), but they execute top-down when the function is called.
The order matters. @bold wraps the result of @italic, which wraps the original function. It's equivalent to writing greet = bold(italic(greet)). The innermost decorator is applied first.
What does this code print?
from functools import wraps
def add_prefix(func):
@wraps(func)
def wrapper(*args, **kwargs):
return '>>> ' + func(*args, **kwargs)
return wrapper
def add_suffix(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs) + ' <<<'
return wrapper
@add_prefix
@add_suffix
def message():
return 'hello'
print(message())Write one print() statement with the exact output.
What Are the Most Useful Real-World Decorators?
Decorators shine in real-world applications. Here are three patterns you'll see constantly: timing, caching, and validation.
Write a decorator validate_positive that checks if all positional arguments to the decorated function are positive numbers (> 0). If any argument is not positive, print 'Error: all arguments must be positive' and return None. Otherwise, call and return the function normally.
@validate_positive
def multiply(a, b):
return a * b
print(multiply(3, 4)) # 12
print(multiply(-1, 5)) # Error: all arguments must be positive \n NoneWrite a decorator retry(max_attempts) that retries a function up to max_attempts times if it raises an exception. Print a message on each retry. If all attempts fail, print the final error.
Test with a counter that fails twice then succeeds:
call_count = 0
@retry(3)
def flaky_function():
global call_count
call_count += 1
if call_count < 3:
raise ValueError('not ready yet')
return 'success!'
print(flaky_function())This decorator is supposed to count how many times a function is called. But it has two bugs. Find and fix them.
def count_calls(func):
count = 0
def wrapper(*args, **kwargs):
count += 1
print(f'Call {count} of {func.__name__}')
return func(*args, **kwargs)
return wrapper
@count_calls
def hello():
print('hello!')
hello()
hello()
hello()Write a decorator memoize that caches function results based on arguments. If the function is called with the same arguments again, return the cached result instead of calling the function.
Print 'Computing...' only when actually computing (not when returning cached results).
@memoize
def square(n):
print('Computing...')
return n ** 2
print(square(4)) # Computing... \n 16
print(square(4)) # 16 (cached!)
print(square(5)) # Computing... \n 25What Should You Remember About Decorators?
Decorators are one of Python's most versatile features. They let you add behavior to functions without modifying them — keeping your code clean, modular, and reusable.
Key takeaways:
@decorator is syntactic sugar for func = decorator(func)@functools.wraps to preserve function metadata@functools.lru_cache for built-in memoization