Custom Python Decorator Patterns Worth Copy-Pasting Forever



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.



Recent Articles

Related Stories

Leave A Reply

Please enter your comment!
Please enter your name here