Decorators¶
In this tutorial, you will learn how to write decorators — functions that modify the behaviour of other functions. Decorators are one of the most powerful and widely used patterns in Python.
Time commitment: 15–20 minutes
Prerequisites:
- Tutorials 01–06 (Defining functions, Lambda expressions, Type hints, Docstrings, Scope and closures, and *args and **kwargs)
- Understanding of closures and
*args/**kwargs
Learning objectives¶
By the end of this tutorial, you will be able to:
- Understand what a decorator is and how it works
- Write a simple decorator that wraps a function
- Use
functools.wrapsto preserve function metadata - Create decorator factories that accept arguments
What is a decorator?¶
A decorator is a function that takes another function as its argument, adds some behaviour, and returns a modified version of that function. Decorators let you extend or alter what a function does without changing its source code.
You have already seen the building blocks for decorators in previous tutorials:
- Functions are first-class objects that can be passed as arguments
- Closures can remember values from their enclosing scope
*argsand**kwargslet you accept any arguments
Functions as arguments¶
Before diving into decorators, let us quickly revisit the idea that functions can be passed as arguments to other functions:
def shout(text):
return text.upper()
def whisper(text):
return text.lower()
def speak(func, message):
"""Apply the given function to the message and print it."""
result = func(message)
print(result)
speak(shout, "Hello, World")
speak(whisper, "Hello, World")
The speak function receives another function as its first argument and calls it. This ability to pass functions around is what makes decorators possible.
Your first decorator¶
Let us write a simple decorator that measures how long a function takes to run. A decorator follows a standard pattern:
- It accepts a function as a parameter
- It defines an inner wrapper function
- The wrapper function calls the original function and adds extra behaviour
- It returns the wrapper function
import time
def timer(func):
"""A decorator that prints how long a function takes to execute."""
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
elapsed = end - start
print(f"{func.__name__} took {elapsed:.4f} seconds")
return result
return wrapper
Now let us use this decorator by applying it to a function:
def slow_sum(numbers):
"""Sum a list of numbers slowly (for demonstration purposes)."""
time.sleep(0.1)
return sum(numbers)
# Apply the decorator manually
slow_sum = timer(slow_sum)
result = slow_sum([1, 2, 3, 4, 5])
print(f"Result: {result}")
The line slow_sum = timer(slow_sum) replaces slow_sum with the wrapper function returned by timer. Now every time you call slow_sum, it automatically prints the execution time.
How decorator syntax works¶
Writing func = decorator(func) every time is a bit cumbersome. Python provides the @ syntax as a cleaner shorthand. The following two approaches are exactly equivalent:
# Without @ syntax
def my_function():
pass
my_function = timer(my_function)
# With @ syntax
@timer
def my_function():
pass
Let us rewrite the previous example using the @ syntax:
@timer
def calculate_total(prices, tax_rate):
"""Calculate the total price including tax."""
subtotal = sum(prices)
return subtotal * (1 + tax_rate)
result = calculate_total([9.99, 14.50, 3.25], 0.20)
print(f"Total: £{result:.2f}")
The @timer line above the function definition is syntactic sugar for calculate_total = timer(calculate_total). It is easier to read and makes the decoration clearly visible.
Preserving function metadata¶
There is a subtle problem with the decorator above. When you decorate a function, the wrapper replaces it, and the original function's name and docstring are lost:
print(f"Function name: {calculate_total.__name__}")
print(f"Docstring: {calculate_total.__doc__}")
The function now reports its name as wrapper instead of calculate_total, and the docstring is gone. This can cause problems with debugging, documentation tools, and introspection.
The solution is functools.wraps, which copies the original function's metadata onto the wrapper:
import functools
def timer(func):
"""A decorator that prints how long a function takes to execute."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
elapsed = end - start
print(f"{func.__name__} took {elapsed:.4f} seconds")
return result
return wrapper
@timer
def calculate_total(prices, tax_rate):
"""Calculate the total price including tax."""
subtotal = sum(prices)
return subtotal * (1 + tax_rate)
print(f"Function name: {calculate_total.__name__}")
print(f"Docstring: {calculate_total.__doc__}")
Now the function's name and docstring are preserved correctly. Always use functools.wraps when writing decorators — it is a best practice that prevents unexpected issues.
A practical decorator: logging function calls¶
Let us build a more practical decorator that logs every call to a function, including its arguments and return value:
def log_calls(func):
"""A decorator that logs function calls with arguments and return values."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
args_repr = [repr(a) for a in args]
kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
all_args = ", ".join(args_repr + kwargs_repr)
print(f"Calling {func.__name__}({all_args})")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
@log_calls
def add(a, b):
"""Return the sum of a and b."""
return a + b
@log_calls
def greet(name, greeting="Hello"):
"""Return a greeting message."""
return f"{greeting}, {name}!"
add(3, 5)
print()
greet("Alice", greeting="Good morning")
This decorator is genuinely useful for debugging — you can apply it to any function to see exactly how it is being called and what it returns.
Decorators with arguments¶
Sometimes you want a decorator that can be configured. For example, a retry decorator where you specify the maximum number of attempts. To achieve this, you write a decorator factory — a function that returns a decorator:
def retry(max_attempts=3):
"""A decorator factory that retries a function on failure."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as error:
print(f"Attempt {attempt}/{max_attempts} failed: {error}")
if attempt == max_attempts:
print("All attempts exhausted. Raising the exception.")
raise
return wrapper
return decorator
Notice the three levels of nesting:
retry(max_attempts)— the factory that accepts configurationdecorator(func)— the actual decorator that accepts the functionwrapper(*args, **kwargs)— the wrapper that runs each time the decorated function is called
Let us test it:
import random
@retry(max_attempts=5)
def unreliable_greeting(name):
"""A function that sometimes fails (for demonstration purposes)."""
if random.random() < 0.6:
raise ConnectionError("Network timeout")
return f"Hello, {name}!"
try:
result = unreliable_greeting("Alice")
print(f"Success: {result}")
except ConnectionError:
print("Function failed after all retry attempts.")
When you write @retry(max_attempts=5), Python first calls retry(max_attempts=5), which returns the decorator function. Then Python applies that decorator to unreliable_greeting.
Stacking decorators¶
You can apply multiple decorators to a single function by stacking them. Decorators are applied from bottom to top (the decorator closest to the function is applied first):
@timer
@log_calls
def multiply(a, b):
"""Return the product of a and b."""
return a * b
multiply(6, 7)
The stacking above is equivalent to:
multiply = timer(log_calls(multiply))
First, log_calls wraps multiply. Then timer wraps the result. When multiply(6, 7) is called, timer runs first (measuring the total time), then log_calls runs (logging the call), and finally the original multiply function runs.
Class-based decorators¶
It is worth knowing that any callable object can serve as a decorator. A class with a __call__ method works just as well as a function. Here is a brief example:
class CountCalls:
"""A decorator that counts how many times a function is called."""
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"{self.func.__name__} has been called {self.count} time(s)")
return self.func(*args, **kwargs)
@CountCalls
def say_hello(name):
"""Return a greeting."""
return f"Hello, {name}!"
print(say_hello("Alice"))
print(say_hello("Bob"))
print(say_hello("Charlie"))
Class-based decorators can be useful when you need to maintain state across calls, as the class instance naturally holds that state. For most use cases, however, function-based decorators with closures are simpler and more common.
Exercise: write a type-checking decorator¶
Write a decorator called validate_types that checks whether all arguments passed to a function are of a specified type. The decorator should accept a single expected_type argument.
For example:
@validate_types(expected_type=int)
def add_numbers(a, b):
return a + b
print(add_numbers(3, 5)) # Expected: 8
print(add_numbers("3", 5)) # Expected: TypeError
Hints:
- This is a decorator factory (it accepts an argument), so you will need three levels of nesting
- Use
isinstance()to check the type of each argument - Remember to use
functools.wraps
Try writing the decorator in the cell below.
# Write your validate_types decorator here
# Test it
# @validate_types(expected_type=int)
# def add_numbers(a, b):
# return a + b
#
# print(add_numbers(3, 5)) # Expected: 8
#
# try:
# print(add_numbers("3", 5)) # Expected: TypeError
# except TypeError as error:
# print(f"Error: {error}")
Solution¶
Here is one way to write the decorator:
def validate_types(expected_type):
"""A decorator factory that validates all arguments are of the expected type."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for i, arg in enumerate(args):
if not isinstance(arg, expected_type):
raise TypeError(
f"Argument {i} is {type(arg).__name__}, "
f"expected {expected_type.__name__}"
)
for key, value in kwargs.items():
if not isinstance(value, expected_type):
raise TypeError(
f"Argument '{key}' is {type(value).__name__}, "
f"expected {expected_type.__name__}"
)
return func(*args, **kwargs)
return wrapper
return decorator
@validate_types(expected_type=int)
def add_numbers(a, b):
"""Return the sum of two integers."""
return a + b
print(add_numbers(3, 5))
try:
print(add_numbers("3", 5))
except TypeError as error:
print(f"Error: {error}")
This decorator factory creates a decorator that inspects every argument before allowing the original function to run. If any argument does not match the expected type, it raises a TypeError with a helpful message.
Summary¶
In this tutorial, you learned how to:
- Write a decorator that wraps a function and adds behaviour
- Use the
@decoratorsyntax as shorthand forfunc = decorator(func) - Use
functools.wrapsto preserve the original function's name and docstring - Create decorator factories that accept configuration arguments
- Stack multiple decorators on a single function
- Use a class with
__call__as a decorator
What is next¶
Congratulations — you have completed all seven tutorials in this series! You now have a solid understanding of Python functions, from basic definitions through to advanced patterns like closures and decorators.
To continue learning, explore the other sections of this site:
- How-to guides — Practical guides for solving specific problems with functions
- Reference — Detailed technical documentation on function syntax, type hints, and more
- Explanation — Deeper discussions on topics like first-class functions, scope, and the philosophy behind Python's design