38. Decorators: Adding Behavior to Functions
Decorators are one of Python's most powerful features for writing clean, reusable code. They allow you to modify or enhance the behavior of functions without changing their actual code. In this chapter, we'll build on your understanding of first-class functions and closures from Chapter 23 to explore how decorators work and how to use them effectively.
38.1) What Decorators Are and Why They're Useful
A decorator is a function that takes another function as input and returns a modified version of that function. This is possible because, as we learned in Chapter 23, functions in Python are first-class objects—they can be passed as arguments and returned from other functions. Decorators let you "wrap" additional behavior around existing functions, making it easy to add common functionality like logging, timing, validation, or access control without cluttering your core logic.
Why Decorators Matter
Imagine you have several functions in your program, and you want to log when each one is called. Without decorators, you might write something like this:
# Without decorators - duplicated logging code
def calculate_total(prices):
print("Calling calculate_total")
result = sum(prices)
print(f"calculate_total returned: {result}")
return result
def find_average(numbers):
print("Calling find_average")
result = sum(numbers) / len(numbers)
print(f"find_average returned: {result}")
return result
def process_order(order_id):
print("Calling process_order")
result = f"Order {order_id} processed"
print(f"process_order returned: {result}")
return result
# Using the functions
calculate_total([10, 20, 30])
# Output:
# Calling calculate_total
# calculate_total returned: 60This approach has several problems:
- Code duplication: The logging lines are repeated in every function
- Mixing concerns: Logging code is mixed with business logic
- Hard to maintain: If you want to change the logging format, you must update every function
- Easy to forget: New functions might not include logging
Decorators solve these problems by letting you separate the logging behavior from your core functions:
# With decorators - clean and maintainable
# (We'll learn how to create @log_calls in this chapter)
@log_calls
def calculate_total(prices):
return sum(prices)
@log_calls
def find_average(numbers):
return sum(numbers) / len(numbers)
@log_calls
def process_order(order_id):
return f"Order {order_id} processed"
# Using the functions produces the same output
calculate_total([10, 20, 30])
# Output:
# Calling calculate_total
# calculate_total returned: 60The difference? The logging behavior is defined once in the @log_calls decorator and reused everywhere. Your core functions stay clean and focused on their primary purpose.
Common Use Cases for Decorators
Decorators are particularly useful for:
- Logging: Recording when functions are called and what they return
- Timing: Measuring how long functions take to execute
- Validation: Checking that function arguments meet certain requirements
- Caching: Storing results of expensive function calls for reuse
- Access control: Checking permissions before allowing function execution
- Retry logic: Automatically retrying failed operations
- Type checking: Validating argument and return types
The key advantage is that you write the decorator once and can apply it to many functions with a single line of code.
38.2) Functions as Objects: The Foundation of Decorators
Before we can understand decorators, we need to review and expand on the concept that functions are first-class objects in Python. As we learned in Chapter 23, this means functions can be assigned to variables, passed as arguments, and returned from other functions.
Functions Can Be Assigned to Variables
When you define a function, Python creates a function object and binds it to a name:
def greet(name):
return f"Hello, {name}!"
# The function object can be assigned to another variable
say_hello = greet
# Both names refer to the same function object
print(greet("Alice")) # Output: Hello, Alice!
print(say_hello("Bob")) # Output: Hello, Bob!The names greet and say_hello both refer to the same function object. This is fundamental to how decorators work.
Functions Can Be Passed as Arguments
You can pass functions to other functions just like any other value:
def apply_twice(func, value):
"""Apply a function to a value twice."""
result = func(value)
result = func(result)
return result
def add_five(x):
return x + 5
result = apply_twice(add_five, 10)
print(result) # Output: 20 (10 + 5 = 15, then 15 + 5 = 20)Here, apply_twice receives the add_five function as an argument and calls it twice.
Functions Can Return Other Functions
A function can create and return a new function:
def make_multiplier(factor):
"""Create a function that multiplies by a specific factor."""
def multiply(x):
return x * factor
return multiply
times_three = make_multiplier(3)
times_five = make_multiplier(5)
print(times_three(10)) # Output: 30
print(times_five(10)) # Output: 50The make_multiplier function returns a new function that "remembers" the factor value through closure (as we learned in Chapter 23).
Wrapping Functions: The Core Decorator Pattern
The decorator pattern combines these concepts: a function that takes a function as input, creates a wrapper function that adds behavior, and returns the wrapper:
def simple_wrapper(original_func):
"""Wrap a function with additional behavior."""
def wrapper():
print("Before calling the function")
result = original_func()
print("After calling the function")
return result
return wrapper
def say_hello():
print("Hello!")
return "greeting"
# Manually wrap the function
wrapped_hello = simple_wrapper(say_hello)
return_value = wrapped_hello()
# Output:
# Before calling the function
# Hello!
# After calling the function
print(f"Returned: {return_value}")
# Output: Returned: greetingLet's trace what happens:
simple_wrapperreceivessay_helloasoriginal_func- It creates a new function
wrapperthat:- Prints "Before calling the function"
- Calls
original_func()(which issay_hello) - Prints "After calling the function"
- Returns the result
simple_wrapperreturns thewrapperfunction- When we call
wrapped_hello(), we're actually callingwrapper, which calls the originalsay_helloinside
This is the core pattern behind all decorators.
Handling Functions with Arguments
The above wrapper only works with functions that take no arguments. To make it work with any function, we need *args and **kwargs:
def flexible_wrapper(original_func):
"""Wrap a function that can accept any arguments."""
def wrapper(*args, **kwargs):
# *args captures positional arguments
# **kwargs captures keyword arguments
print("Before calling the function")
result = original_func(*args, **kwargs)
print("After calling the function")
return result
return wrapper
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
# Manually wrap the function
greet = flexible_wrapper(greet)
result = greet("Alice")
# Output:
# Before calling the function
# After calling the function
print(result)
# Output: Hello, Alice!
result = greet("Bob", greeting="Hi")
# Output:
# Before calling the function
# After calling the function
print(result)
# Output: Hi, Bob!How *args and **kwargs work:
As we learned in Chapter 20, *args and **kwargs allow functions to accept a variable number of arguments:
*argscollects all positional arguments into a tuple**kwargscollects all keyword arguments into a dictionary- When we call
original_func(*args, **kwargs), we unpack them back as arguments to the original function
This pattern allows our wrapper to work with any function, regardless of how many arguments it takes.
Moving to Cleaner Syntax
This pattern is the foundation of decorators. The decorator syntax we'll learn next is just a cleaner way to apply this pattern. Instead of writing:
greet = flexible_wrapper(greet)We'll use the @ syntax:
@flexible_wrapper
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"Both do exactly the same thing—the @ syntax is just syntactic sugar that makes the code cleaner and more readable.
38.3) The @decorator Syntax: Cleaner Application
Writing function_name = decorator(function_name) works, but it's verbose and easy to forget. Python provides the @decorator syntax as a cleaner way to apply decorators.
Using the @ Symbol
Instead of manually wrapping a function, you can place @decorator_name on the line immediately before the function definition:
def log_call(func):
"""Decorator that logs function calls."""
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned: {result}")
return result
return wrapper
@log_call
def calculate_total(prices):
return sum(prices)
@log_call
def find_average(numbers):
return sum(numbers) / len(numbers)
# Use the decorated functions
total = calculate_total([10, 20, 30])
# Output:
# Calling calculate_total
# calculate_total returned: 60
print(f"Total: {total}")
# Output: Total: 60
average = find_average([10, 20, 30])
# Output:
# Calling find_average
# find_average returned: 20.0
print(f"Average: {average}")
# Output: Average: 20.0The @log_call syntax is exactly equivalent to writing:
def calculate_total(prices):
return sum(prices)
calculate_total = log_call(calculate_total)But the @ syntax is much cleaner and makes it immediately obvious that the function is decorated.
Stacking Multiple Decorators
You can apply multiple decorators to the same function by stacking them:
import time
def log_call(func):
"""Decorator that logs function calls."""
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned: {result}")
return result
return wrapper
def timer(func):
"""Decorator that times function execution."""
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start_time
print(f"{func.__name__} took {elapsed:.4f} seconds")
return result
return wrapper
@timer
@log_call
def process_data(items):
total = sum(items)
return total * 2
result = process_data([1, 2, 3, 4, 5])
# Output:
# Calling process_data
# process_data returned: 30
# process_data took 0.0001 seconds
print(f"Final result: {result}")
# Output: Final result: 30When decorators are stacked, they're applied from bottom to top (closest to the function first):
@timer # Applied second (outermost layer)
@log_call # Applied first (closest to function)
def process_data(items):
passThis is equivalent to:
process_data = timer(log_call(process_data))Application order (bottom to top):
@log_callwraps the original function first@timerwraps the result (wraps the already-wrapped function)
Execution order (top to bottom, outermost to innermost):
timerwrapper starts (outermost, executes first)log_callwrapper starts (inner wrapper)- Original function executes
log_callwrapper finishestimerwrapper finishes (outermost, finishes last)
Think of decorators like layers of wrapping paper—you apply them from the inside out, but when you unwrap (execute), you go from the outside in.
Decorator Application:
Execution Flow:
38.4) Practical Decorator Examples (Logging, Timing, Validation)
Now let's explore several practical decorators you might use in real programs. These examples demonstrate common patterns and show how decorators solve real-world problems.
Example 1: Enhanced Logging Decorator
A more sophisticated logging decorator that includes timestamps and handles exceptions:
import time
def log_with_timestamp(func):
"""Decorator that logs function calls with timestamps."""
def wrapper(*args, **kwargs):
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] Calling {func.__name__}")
try:
result = func(*args, **kwargs)
print(f"[{timestamp}] {func.__name__} completed successfully")
return result
except Exception as e:
print(f"[{timestamp}] {func.__name__} raised {type(e).__name__}: {e}")
raise
return wrapper
@log_with_timestamp
def divide(a, b):
return a / b
@log_with_timestamp
def process_user(user_id):
# Simulate processing
if user_id < 0:
raise ValueError("User ID must be positive")
return f"Processed user {user_id}"
# Test successful execution
result = divide(10, 2)
# Output:
# [2025-12-31 10:30:45] Calling divide
# [2025-12-31 10:30:45] divide completed successfully
print(f"Result: {result}")
# Output: Result: 5.0
# Test successful execution with validation
user = process_user(42)
# Output:
# [2025-12-31 10:30:45] Calling process_user
# [2025-12-31 10:30:45] process_user completed successfully
print(user)
# Output: Processed user 42
# Test exception handling
try:
divide(10, 0)
# Output:
# [2025-12-31 10:30:45] Calling divide
# [2025-12-31 10:30:45] divide raised ZeroDivisionError: division by zero
except ZeroDivisionError:
print("Handled division by zero")
# Output: Handled division by zero
try:
process_user(-5)
# Output:
# [2025-12-31 10:30:45] Calling process_user
# [2025-12-31 10:30:45] process_user raised ValueError: User ID must be positive
except ValueError:
print("Handled invalid user ID")
# Output: Handled invalid user IDThis decorator:
- Adds timestamps to all log messages
- Logs both successful completions and exceptions
- Re-raises exceptions after logging them (using
raisewithout an argument) - Uses a
try/exceptblock to catch and log any exception
Example 2: Performance Timing Decorator
A decorator that measures and reports function execution time:
import time
def measure_time(func):
"""Decorator that measures and reports execution time."""
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
# Format time appropriately
if elapsed < 0.001:
time_str = f"{elapsed * 1000000:.2f} microseconds"
elif elapsed < 1:
time_str = f"{elapsed * 1000:.2f} milliseconds"
else:
time_str = f"{elapsed:.2f} seconds"
print(f"{func.__name__} executed in {time_str}")
return result
return wrapper
@measure_time
def find_primes(limit):
"""Find all prime numbers up to limit."""
primes = []
for num in range(2, limit):
is_prime = True
for divisor in range(2, int(num ** 0.5) + 1):
if num % divisor == 0:
is_prime = False
break
if is_prime:
primes.append(num)
return primes
@measure_time
def calculate_factorial(n):
"""Calculate factorial of n."""
result = 1
for i in range(1, n + 1):
result *= i
return result
# Test the decorated functions
primes = find_primes(1000)
# Output: find_primes executed in 15.23 milliseconds
print(f"Found {len(primes)} primes")
# Output: Found 168 primes
factorial = calculate_factorial(100)
# Output: calculate_factorial executed in 45.67 microseconds
print(f"Factorial has {len(str(factorial))} digits")
# Output: Factorial has 158 digitsThis decorator automatically formats the time measurement appropriately (microseconds, milliseconds, or seconds) based on the duration.
Example 3: Input Validation Decorator
A decorator that validates function arguments before execution:
def validate_positive(func):
"""Decorator that ensures all numeric arguments are positive."""
def wrapper(*args, **kwargs):
# Check positional arguments
for i, arg in enumerate(args):
if isinstance(arg, (int, float)) and arg <= 0:
raise ValueError(
f"Argument {i} to {func.__name__} must be positive, got {arg}"
)
# Check keyword arguments
for key, value in kwargs.items():
if isinstance(value, (int, float)) and value <= 0:
raise ValueError(
f"Argument '{key}' to {func.__name__} must be positive, got {value}"
)
return func(*args, **kwargs)
return wrapper
@validate_positive
def calculate_area(width, height):
"""Calculate area of a rectangle."""
return width * height
@validate_positive
def calculate_discount(price, discount_percent):
"""Calculate discounted price."""
discount = price * (discount_percent / 100)
return price - discount
# Test valid inputs
area = calculate_area(10, 5)
print(f"Area: {area}")
# Output: Area: 50
discounted = calculate_discount(100, 20)
print(f"Discounted price: ${discounted:.2f}")
# Output: Discounted price: $80.00
# Test invalid inputs
try:
calculate_area(-5, 10)
except ValueError as e:
print(f"Validation error: {e}")
# Output: Validation error: Argument 0 to calculate_area must be positive, got -5
try:
calculate_discount(100, discount_percent=-10)
except ValueError as e:
print(f"Validation error: {e}")
# Output: Validation error: Argument 'discount_percent' to calculate_discount must be positive, got -10This decorator:
- Checks all numeric arguments (both positional and keyword)
- Raises a descriptive error if any are not positive
- Provides clear error messages indicating which argument failed validation
38.5) (Optional) Decorators with Arguments
So far, all our decorators have been simple functions that take a function as input. But what if you want to configure a decorator's behavior? For example, you might want a retry decorator where you can specify the number of attempts, or a logging decorator where you can specify the log level.
Decorators with arguments require an extra level of function nesting. Instead of a decorator being a function that takes a function, it becomes a function that takes arguments and returns a decorator.
The Pattern: Decorator Factories
A decorator with arguments is actually a decorator factory - a function that creates and returns a decorator. The key to understanding this is knowing what Python does with the @ symbol.
The Key Principle: Python Evaluates @ First
Python always evaluates whatever comes after @ first, then uses the result to decorate your function.
Let's compare:
A) Basic Decorator:
Based on this example:
def log_call(func):
"""Decorator that logs function calls."""
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned: {result}")
return result
return wrapper
@log_call
def greet(name):
return f"Hello, {name}!"What Python does:
- Evaluate
@log_call→ Result:log_callitself (the function object) - Apply to
greet:greet = log_call(greet)
B) Decorator Factory:
Based on this example:
def repeat(times):
"""Level 1: Factory - receives configuration"""
def decorator(func):
"""Level 2: Decorator - receives the function to decorate"""
def wrapper(*args, **kwargs):
"""Level 3: Wrapper - executes when decorated function is called"""
for i in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
# Output:
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!What Python does:
- Evaluate
@repeat(3)→ Result:repeat(3)is called, returns a decorator function - Apply that decorator to
greet:greet = decorator(greet)
The difference: @log_call gives you the function itself, but @repeat(3) calls a function (repeat) that returns a decorator.
Understanding the Three Levels
A decorator factory has three nested functions, each with a specific role:
def repeat(times): # Level 1: Factory
def decorator(func): # Level 2: Decorator
def wrapper(*args, **kwargs): # Level 3: Wrapper
for i in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decoratorLevel 1 - Factory (repeat):
- Takes: Configuration (
times) - Returns: A decorator function
- Called: When Python evaluates
@repeat(3)
Level 2 - Decorator (decorator):
- Takes: The function to decorate (
func) - Returns: A wrapper function
- Called: Immediately after Level 1, as part of the @ syntax
Level 3 - Wrapper (wrapper):
- Takes: The function's arguments when called (
*args, **kwargs) - Returns: The result
- Called: Every time you call the decorated function
Step-by-Step Execution
Let's trace what happens with @repeat(3):
# What you write:
@repeat(3)
def greet(name):
print(f"Hello, {name}!")Step 1: Python evaluates repeat(3)
decorator = repeat(3) # Factory returns a decorator (times=3 is captured)Step 2: Python applies the decorator to greet
def greet(name):
print(f"Hello, {name}!")
greet = decorator(greet) # Decorator returns a wrapper (func=greet is captured)Note: At this point, greet now refers to the wrapper function. The original greet is captured in func.
Step 3: When you call greet("Alice"), the wrapper executes
greet("Alice") # Actually calls wrapper("Alice")
# wrapper uses the captured 'times' and 'func'Why Three Levels?
Each level captures different information through closures:
def repeat(times): # Captures: times
def decorator(func): # Captures: func (and remembers times)
def wrapper(*args, **kwargs): # Captures: times, func, and receives args
for i in range(times): # Uses captured 'times'
result = func(*args, **kwargs) # Uses captured 'func' and 'args'
return result
return wrapper
return decorator- Level 1 captures the configuration (
times) - Level 2 captures the function to decorate (
func) - Level 3 receives the arguments when called (
args,kwargs)
Without all three levels, we couldn't have a configurable decorator that remembers both its settings and the function it's decorating.
Example 1: A Configurable Logging Decorator
Here's a practical example of a logging decorator that accepts configuration:
def log_with_prefix(prefix="LOG"):
"""Decorator factory that creates a logging decorator with a custom prefix."""
def decorator(func):
def wrapper(*args, **kwargs):
print(f"[{prefix}] Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"[{prefix}] {func.__name__} returned: {result}")
return result
return wrapper
return decorator
@log_with_prefix(prefix="INFO")
def calculate_total(prices):
return sum(prices)
@log_with_prefix() # Use default prefix
def get_average(numbers):
return sum(numbers) / len(numbers)
# Test the decorated functions
total = calculate_total([10, 20, 30])
# Output:
# [INFO] Calling calculate_total
# [INFO] calculate_total returned: 60
print(f"Total: {total}")
# Output: Total: 60
average = get_average([10, 20, 30])
# Output:
# [LOG] Calling get_average
# [LOG] get_average returned: 20.0
print(f"Average: {average}")
# Output: Average: 20.0Notice that:
@log_with_prefix(prefix="INFO")uses a custom prefix@log_with_prefix()uses the default prefix "LOG"- You must include parentheses even when using defaults
Example 2: A Decorator with Multiple Arguments
Here's a decorator that validates numeric ranges:
def validate_range(min_value=None, max_value=None):
"""
Decorator factory that validates numeric arguments are within a range.
Args:
min_value: Minimum allowed value (inclusive)
max_value: Maximum allowed value (inclusive)
"""
def decorator(func):
def wrapper(*args, **kwargs):
# Check all numeric arguments
all_args = list(args) + list(kwargs.values())
for arg in all_args:
if isinstance(arg, (int, float)):
if min_value is not None and arg < min_value:
raise ValueError(
f"{func.__name__} received {arg}, "
f"which is below minimum {min_value}"
)
if max_value is not None and arg > max_value:
raise ValueError(
f"{func.__name__} received {arg}, "
f"which is above maximum {max_value}"
)
return func(*args, **kwargs)
return wrapper
return decorator
@validate_range(min_value=0, max_value=100)
def calculate_percentage(value, total):
"""Calculate percentage."""
return (value / total) * 100
@validate_range(min_value=0)
def calculate_age(birth_year, current_year):
"""Calculate age from birth year."""
return current_year - birth_year
# Test valid inputs
percentage = calculate_percentage(25, 100)
print(f"Percentage: {percentage}%")
# Output: Percentage: 25.0%
age = calculate_age(1990, 2025)
print(f"Age: {age}")
# Output: Age: 35
# Test invalid inputs
try:
calculate_percentage(150, 100)
except ValueError as e:
print(f"Validation error: {e}")
# Output: Validation error: calculate_percentage received 150, which is above maximum 100
try:
calculate_age(-5, 2025)
except ValueError as e:
print(f"Validation error: {e}")
# Output: Validation error: calculate_age received -5, which is below minimum 0When to Use Decorators with Arguments
Use decorators with arguments when:
- You need to configure the decorator's behavior
- The same decorator should work differently in different contexts
- You want to make decorators more reusable and flexible
Common examples include:
- Retry decorators with configurable attempts and delays
- Logging decorators with configurable log levels or formats
- Validation decorators with configurable rules
- Caching decorators with configurable cache sizes or expiration times
- Rate limiting decorators with configurable limits
A Note on Complexity
Decorators with arguments add an extra level of complexity. When writing them:
- Use clear, descriptive parameter names
- Provide sensible default values
- Include docstrings explaining the parameters
- Consider whether the added flexibility is worth the complexity
For simple cases, a decorator without arguments is often clearer and easier to understand.
Decorators are a powerful tool for writing clean, maintainable Python code. They let you separate cross-cutting concerns (like logging, timing, and validation) from your core business logic, making your code easier to read, test, and modify. As you continue programming in Python, you'll find decorators used extensively in frameworks and libraries, and you'll discover many opportunities to write your own decorators to solve common problems elegantly.