Image by Author | Ideogram
If you’ve been writing Python for a while, you’ve probably seen and used decorators. While many developers understand the basics of decorators, having a collection of useful and reusable patterns can improve your code quality and your productivity.
In this article, we’ll look at five decorator patterns that are worth adding to your toolkit. For each one, a sample implementation with examples along the way. Let’s get started.
🔗 Link to the code on GitHub
What Are Decorators, Anyway?
Before diving in, let’s quickly recap what decorators are. At their core, decorators are functions that modify the behavior of other functions (or classes) without changing their source code. They follow this simple pattern:
@decorator
def function():
pass
Which is syntactic sugar for:
function = decorator(function)
Now let’s get to the good stuff.
1. Memoization
Let’s say you have a function that takes noticeable time to compute something based on inputs. For example, complex math, intensive operations on large datasets, API calls, and more. Recomputing it every time is wasteful.
Here’s a simple memoization decorator:
def memoize(func):
"""Caches the return value of a function based on its arguments."""
cache =
def wrapper(*args, **kwargs):
# Create a key that uniquely identifies the function call
key = str(args) + str(kwargs)
if key not in cache:
cache[key] = func(*args, **kwargs)
return cache[key]
return wrapper
Example: Fibonacci Numbers
Let’s say you have a function to calculate Fibonacci numbers recursively. You can memoize it like so:
@memoize
def fibonacci(n):
"""Calculate the nth Fibonacci number."""
if n
Output:
The 50th Fibonacci number is 12586269025
Without memoization, calculating fibonacci(50) would take longer due to the recursive calls. With memoization, it’s nearly instant.
When to use: Use this pattern when you’re reprocessing the same inputs, and function results don’t change over time.
2. Logging
You don’t always want to sprinkle print and other debug lines inside your functions. Especially when you want consistent logs across several functions.
This decorator logs information about function calls, useful for debugging and monitoring:
import logging
import functools
def log_calls(func=None, level=logging.INFO):
"""Log function calls with arguments and return values."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
args_str = ", ".join([str(a) for a in args])
kwargs_str = ", ".join([f"k=v" for k, v in kwargs.items()])
all_args = f"args_str', ' if args_str and kwargs_str else ''kwargs_str"
logging.log(level, f"Calling func.__name__(all_args)")
result = func(*args, **kwargs)
logging.log(level, f"func.__name__ returned result")
return result
return wrapper
# Handle both @log_calls and @log_calls(level=logging.DEBUG)
if func is None:
return decorator
return decorator(func)
Example: Logging Function Calls
Here’s how you can use the decorators we’ve created:
logging.basicConfig(level=logging.INFO)
@log_calls
def divide(a, b):
return a / b
# This will log the call and the return value
result = divide(10, 2)
# You can also customize the logging level
@log_calls(level=logging.DEBUG)
def multiply(a, b):
return a * b
result = multiply(5, 4)
When to use: This is handy for small scripts, API endpoints, or debugging batch jobs.
3. Timing Execution
Sometimes you want a quick benchmark of how long a function takes. This is especially useful when you have code that hits a database, parses files, or trains models.
import time
import functools
def timeit(func):
"""Measure and print the execution time of a function."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"func.__name__ took end_time - start_time:.4f seconds to run")
return result
return wrapper
Example: Timing a Function
Let’s time a sample function call:
@timeit
def slow_function():
"""A deliberately slow function for demonstration."""
total = 0
for i in range(10000000):
total += i
return total
result = slow_function() # Will print execution time
Output:
slow_function took 0.5370 seconds to run
When to use: Such a decorator (you can think of a better name for the decorator, though) is helpful to time simple functions to identify scope for optimization.
4. Retry on Failure
When working with external services or flaky operations, retrying on failure can make your code more robust.
def retry(max_attempts=3, delay_seconds=1, backoff_factor=2, exceptions=(Exception,)):
"""Retry a function if it raises specified exceptions."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
attempts = 0
current_delay = delay_seconds
while attempts
Well, how does that work? This retry decorator automatically retries a function when it encounters specified exceptions, up to a maximum number of attempts. It implements an exponential backoff strategy where the delay between retries increases by a configurable factor after each attempt.
The decorator accepts parameters to customize its behavior: maximum attempts, initial delay duration, backoff multiplier, and which exception types to catch. It logs warnings during retries and a final error if all attempts fail, ultimately re-raising the last exception.
We use nested functions to create a closure that preserves the retry configuration while wrapping the target function.
Example: Querying API with Retries
Note: Make sure you have Requests installed.
Here we try to fetch data from an API with retry logic:
import random
import requests
@retry(max_attempts=5, delay_seconds=1, exceptions=(requests.RequestException,))
def fetch_data(url):
"""Fetch data from an API with retry logic."""
response = requests.get(url, timeout=2)
response.raise_for_status() # Raise exception for 4XX/5XX responses
return response.json()
# This will retry up to 5 times if the request fails
try:
data = fetch_data('https://api.example.com/data')
print("Successfully fetched data!")
except Exception as e:
print(f"All retry attempts failed: e")
When to use: This is great for network code, flaky APIs, or anything IO-bound.
5. Input Validation
You can wrap a function to automatically validate inputs without bloating the function itself.
Here’s a decorator to ensure inputs are positive integers:
def validate_positive_ints(func):
def wrapper(*args):
for arg in args:
if not isinstance(arg, int) or arg
Example: Validating Positive Inputs
Before the function runs, we loop through the arguments and validate them:
@validate_positive_ints
def calculate_area(length, width):
return length * width
print(calculate_area(5, 10))
print(calculate_area(-1, 10))
While the first call returns the expected value of 50, we get a ValueError exception for the second function call.
50
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
in ()
4
5 print(calculate_area(5, 10))
----> 6 print(calculate_area(-1, 10))
in wrapper(*args)
3 for arg in args:
4 if not isinstance(arg, int) or arg 5 raise ValueError(f"arg must be a positive integer")
6 return func(*args)
7 return wrapper
ValueError: -1 must be a positive integer |
When to use: Useful when working with data pipelines and user inputs.
Wrapping Up
So yeah, you can think of decorators as wrappers that help you cleanly “bolt on” extra behaviors. The five patterns we’ve covered—memoization, logging, timing, retry logic, and input validation—are my go-to solutions for common programming tasks.
By using these patterns in your projects (or better yet, creating a small utility module for them), you’ll write cleaner, more maintainable code and save yourself hours of duplicated effort.
Think about what aspects of your code can be abstracted away and applied consistently across your entire codebase. Once you start seeing your code through this lens, you’ll find uses for decorators.
What’s your favorite decorator pattern? Have you created any custom ones that have become indispensable to your workflow? Let us know.
Bala Priya C is a developer and technical writer from India. She likes working at the intersection of math, programming, data science, and content creation. Her areas of interest and expertise include DevOps, data science, and natural language processing. She enjoys reading, writing, coding, and coffee! Currently, she’s working on learning and sharing her knowledge with the developer community by authoring tutorials, how-to guides, opinion pieces, and more. Bala also creates engaging resource overviews and coding tutorials.