Ever encountered a Python function with an @ symbol perched on top? That’s the decorator syntax, a powerful feature for enhancing functions without altering their core logic.
A decorator is a function that “wraps” another function, adding new capabilities before or after the original function runs.
graph TD;
A[Decorator Function] -- Receives --> B(Base Function);
A -- Defines & Returns --> C{Enhanced Wrapper Function};
C -- Executes --> D[Setup Code e.g., Start Timer];
D -- Calls --> B;
B -- Executes --> E[Teardown Code e.g., Stop Timer];
In essence, when you apply @my_decorator to my_function, you’re telling Python to replace my_function with an enhanced version created by my_decorator.
The Problem: Code That Does Too Much
Why not just add the extra logic directly into the function? Let’s consider a simple brew_beverage function.
import time
def brew_beverage():
print("Brewing beverage...")
time.sleep(1)
print("Beverage is ready!")
brew_beverage()
This function works, but what if we want to time its exact execution? The naive approach is to modify it directly.
import time
+import datetime
def brew_beverage():
+ start_time = datetime.datetime.now()
print("Brewing beverage...")
time.sleep(1)
print("Beverage is ready!")
+ end_time = datetime.datetime.now()
+ print(f"Execution time: {end_time - start_time}")
This “solution” introduces two significant problems:
- Violation of the Single Responsibility Principle: The function is now doing two things: brewing a beverage and timing itself. Functions should be focused and do one thing well.
- Poor Reusability: If we create another function, say
prepare_coffee, and want to time it, we’d have to duplicate the timing logic. This makes the codebase repetitive and hard to maintain.
Decorators solve these issues elegantly.
Building Your First Decorator
Let’s create a timing_decorator to handle the performance measurement. Since decorators are just functions, we define one that accepts a function (base_func) as its argument.
Inside the decorator, we define a wrapper function. This wrapper will contain the timing logic and, crucially, a call to the base_func it’s wrapping. The decorator then returns this wrapper function.
import time
import datetime
# This is our decorator
def timing_decorator(base_func):
# This is the function that will be returned
def wrapper():
start_time = datetime.datetime.now()
base_func() # Call the original function
end_time = datetime.datetime.now()
print(f"[{base_func.__name__}] Execution time: {end_time - start_time}")
return wrapper
# A simple function to decorate
def brew_beverage():
"""Simulates brewing a beverage."""
print("Brewing beverage...")
time.sleep(1)
print("Beverage is ready!")
# Manual application of the decorator
timed_brew = timing_decorator(brew_beverage)
timed_brew()
When we run this, timed_brew is the wrapper function returned by our decorator. Calling it executes the timing logic around the original brew_beverage function.
The Magic of @ Syntax
While manually applying decorators works, Python provides a much cleaner, more expressive syntax: the @ symbol.
Applying @timing_decorator above a function definition is syntactic sugar for brew_beverage = timing_decorator(brew_beverage).
@timing_decorator
def brew_beverage():
"""Simulates brewing a beverage."""
print("Brewing beverage...")
time.sleep(1)
print("Beverage is ready!")
@timing_decorator
def prepare_coffee():
"""Simulates preparing coffee."""
print("Grinding coffee beans...")
time.sleep(1.5)
print("Coffee is ready!")
# Now, calling the functions directly executes the decorated behavior
brew_beverage()
prepare_coffee()
This approach is superior because it makes the enhancement explicit and keeps the decoration coupled with the function definition. We’ve also effortlessly reused our timing logic for prepare_coffee!
Level Up: Handling Arguments and Keywords
What happens when our decorated functions have parameters?
Let’s modify brew_beverage to accept arguments.
@timing_decorator
-def brew_beverage():
+def brew_beverage(beverage_type="tea", steep_time=1):
"""Simulates brewing a beverage."""
- print("Brewing beverage...")
- time.sleep(1)
+ print(f"Brewing {beverage_type}...")
+ time.sleep(steep_time)
print("Beverage is ready!")
-brew_beverage()
+brew_beverage("green tea", steep_time=2)
Running this code will raise a TypeError! The wrapper function inside our decorator is defined to accept zero arguments, but we’re trying to pass two.
The solution is to make our wrapper flexible enough to accept any combination of arguments using *args and **kwargs.
*args: Packs all positional arguments into a tuple.**kwargs: Packs all keyword arguments into a dictionary.
We then use the unpacking operators (* and **) to forward these arguments to the base_func.
graph TD
subgraph "Function Call"
A("brew_beverage('green tea', steep_time=2)")
end
subgraph "Decorator Wrapper"
B["wrapper(*args, **kwargs)"]
end
subgraph "Base Function Call"
C["base_func(*args, **kwargs)"]
end
A -- "('green tea',) -> args<br>{'steep_time': 2} -> kwargs" --> B;
B -- "Unpacks args and kwargs" --> C;
Here is the improved, robust decorator:
def timing_decorator(base_func):
def wrapper(*args, **kwargs): # Accept any arguments
start_time = datetime.datetime.now()
base_func(*args, **kwargs) # Forward them to the base function
end_time = datetime.datetime.now()
print(f"[{base_func.__name__}] Execution time: {end_time - start_time}")
return wrapper
Now our decorator works flawlessly with functions that take any number of arguments.
Final Polish: Handling Return Values
There’s one last piece to the puzzle: what if the decorated function returns a value?
from datetime import datetime, timedelta
@timing_decorator
def prepare_coffee():
"""Simulates preparing coffee and returns an optimal consumption time."""
print("Grinding coffee beans...")
time.sleep(1.5)
drink_by = datetime.now() + timedelta(minutes=20)
return f"Coffee is ready! Drink by {drink_by.strftime('%H:%M:%S')}."
drink_time = prepare_coffee()
print(drink_time)
# Output: None
The output is None because our wrapper function doesn’t return anything. We need to capture the result of the base_func call and return it from the wrapper.
def timing_decorator(base_func):
def wrapper(*args, **kwargs):
start_time = datetime.datetime.now()
- base_func(*args, **kwargs)
+ result = base_func(*args, **kwargs) # Capture the return value
end_time = datetime.datetime.now()
print(f"[{base_func.__name__}] Execution time: {end_time - start_time}")
+ return result # Return it
return wrapper
With this change, our decorator is now fully flexible and transparently handles arguments and return values.
[!TIP] Best Practice: Preserve Function Metadata Decorators have a side effect: they obscure the original function’s metadata (like its name
__name__and docstring__doc__). The decorated function will incorrectly report itself aswrapper.To fix this, always use
@functools.wrapsfrom Python’s standard library. This is a decorator for your decorator! It copies the metadata from the original function to the wrapper.
Here is the final, production-ready decorator pattern:
import time
import datetime
import functools # Import functools
def timing_decorator(base_func):
@functools.wraps(base_func) # Apply the wraps decorator
def wrapper(*args, **kwargs):
start_time = datetime.datetime.now()
result = base_func(*args, **kwargs)
end_time = datetime.datetime.now()
print(f"[{base_func.__name__}] Execution time: {end_time - start_time}")
return result
return wrapper
@timing_decorator
def brew_beverage(beverage_type="tea", steep_time=1):
"""Simulates brewing a beverage."""
print(f"Brewing {beverage_type}...")
time.sleep(steep_time)
print("Beverage is ready!")
print(brew_beverage.__name__)
# Output: brew_beverage (Correct!)
By mastering this pattern, you can create powerful, reusable components for logging, caching, authentication, and much more, leading to cleaner and more professional Python code.