Skip to main content

Command Palette

Search for a command to run...

Advanced Python Decorators Patterns: Arguments and Stacking

The patterns you'll actually encounter in real codebases.

Published
9 min read
Advanced Python Decorators Patterns: Arguments and Stacking
M
I'm Moussa, a Beninese Software Engineer based in Rwanda. I took what many considered an unlikely path, pivoting from Diplomacy and International Relations to Software Engineering. That career switch taught me something I carry into everything I do: the best learning happens when you're willing to start from zero. I work as a full-time software engineer and contribute to nonprofits supporting African students and sport talents in their education. I'm passionate about EdTech and AI and their potential to empower learners across Sub-Saharan Africa. Writing is how I process what I learn and give it back — every article here is something I wish I'd found when I was figuring it out myself. Fun facts: I'm a certified IJF judo instructor, I taught myself English through YouTube, and I'm always one rabbit hole away from picking up something new.

You've learned how decorators work. You've survived the print vs return trap. Now it's time for the patterns you'll actually encounter in the wild: decorators that take arguments and decorators that stacked on top of each other.

If you've ever wondered how FastAPI's @app.get("/shipments") work under the hood, this is the article for you.


The Problem with Simple Decorators

Our decorators so far have all looked like this:

@my_decorator
def some_function():
    pass

The decorator receives the function, wraps it, and returns the wrapper. Clean and simple.

But what if you want a decorator that repeats a function a configurable number of times? You want to use it like this:

@repeat(3)
def say_hello():
    print("Hello!")

say_hello()
# Hello!
# Hello!
# Hello!

And on another function:

@repeat(5)
def say_goodbye():
    print("Goodbye!")

Same decorator, different configuration. How do we build this?


Your First Instinct Is Wrong (And That's OK)

Your first thought might be: "Just add the number as another parameter."

def repeat(func, n):  # <-- this won't work with @
    def wrapper(*args, **kwargs):
        for _ in range(n):
            result = func(*args, **kwargs)
        return result
    return wrapper

But this breaks with the @ syntax. When Python sees @repeat(3), it calls @repeat(3) before it knows about the function. So repeat receives 3, not a function. There's no way to pass both the number and the function at the same time.

@repeat and @repeat(3) are not the same pattern. The first expect repeat itself to be a decorator. The second expect repeat(3) to return a decorator.


Understanding What @repeat(3) Actually Does

Let's slow down and think about what Python does when it sees:

@repeat(3)
def say_hello():
    print("Hello!")

It evaluates this in two steps:

  1. First, it calls repeat(3). This must return a decorator.

  2. Then, it applies that decorator to say_hello.

Written out explicitly:

# Step 1: repeat(3) returns a decorator
decorator = repeat(3)

# Step 2: that decorator wraps say_hello
say_hello = decorator(say_hello)

Or in one line: say_hello = repeat(3)(say_hello).

This means repeat is not the decorator itself anymore. It's a function that builds and returns a decorator. One extra layer.

This wrapping happens once, when Python executes the def statement. After that, the name say_hello no longer refers to the original function. It refers to the wrapper returned by the decorator.


Building It Step by Step

Let's start with something we already know, a decorator with the number hardcoded:

from functools import wraps

def repeat_three(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        result = None
        for _ in range(3):
            result = func(*args, **kwargs)
        return result
    return wrapper


@repeat_three
def say_hello():
    print("Hello!")
💡
Remember from the first article in this series: You will usually use functools.wraps(func) on the wrapper so the decorated function keeps metadata like its original name and docstring.

This works, but 3 is baked in. To make it configurable, wrap the whole thing in a function that takes n:

from functools import wraps

def repeat(n):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

Three layers. Read them from the outside in:

  • repeat(n) takes your configuration argument

  • decorator(func) takes the function being decorated (this is the actual decorator)

  • wrapper(*args, **kwargs) replaces the original function

That's it. Now it works with any number:

@repeat(3)
def say_hello():
    print("Hello!")

@repeat(5)
def say_goodbye():
    print("Goodbye!")

say_hello()
# Hello!
# Hello!
# Hello!

say_goodbye()
# Goodbye!  (x5)

Notice that repeat returns the result from the final execution of the function. If the function returns different values on each call, only the last one is preserved.

💡
If n were 0, this implementation would return None, so in real code you may want to validate that n >= 1.

Let's Trace Through the Execution

When Python sees @repeat(3) above say_hello, here's exactly what happens:

  1. Python calls repeat(3)

  2. repeat(3) returns the decorator function, with n=3 remembered (closure)

  3. Python applies that decorator to say_hello: decorator(say_hello)

  4. decorator(say_hello) returns the wrapper function, with func=say_hello remembered

  5. say_hello is now replaced by wrapper

  6. When you call say_hello(), you're calling wrapper()

  7. wrapper runs the original say_hello 3 times (because n=3 is remembered)

Closures are doing all the heavy lifting here. Each layer remembers the values from the layer above it.

A closure is a function that remembers variables from the scope where it was created, even after that outer function has finished running.


Practical Example: A Retry Decorator

Here's a decorator that retries a function if it throws an exception:

from functools import wraps
import time

def retry(max_attempts):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Attempt {attempt} failed: {e}")
                    if attempt < max_attempts:
                        print("Retrying...")
                        time.sleep(1)
                    else:
                        print("All attempts failed!")
                        raise # Re-raise the exception if all attempts exhausted
        return wrapper
    return decorator

@retry(3)
def connect_to_server():
    print("Trying to connect...")
    raise ConnectionError("Server is down")

connect_to_server()
# Trying to connect...
# Attempt 1 failed: Server is down
# Retrying...
# Trying to connect...
# Attempt 2 failed: Server is down
# Retrying...
# Trying to connect...
# Attempt 3 failed: Server is down
# All attempts failed!

Notice how the return inside the try block exits the wrapper immediately on success. The loop only continues if an exception is caught.

This is a simplified example for learning. Real retry logic usually catches specific exceptions, may use exponential backoff, and often avoids retrying on every kind of failure.


Practical Example: Role-Based Access Control

from functools import wraps
 
def require_role(role):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            current_user_role = "editor"  # imagine this from a database
            if current_user_role != role:
                print(f"Access denied! You need '{role}' role.")
                return None
            return func(*args, **kwargs)
        return wrapper
    return decorator

@require_role("admin")
def delete_user(user_id):
    print(f"User {user_id} deleted!")

@require_role("editor")
def edit_post(post_id):
    print(f"Post {post_id} edited!")

delete_user(42)   # Access denied! You need 'admin' role.
edit_post(7)      # Post 7 edited!
In real code, current_user_role would be injected (e.g, from a request context or a passed argument), not hardcoded. The hardcoded "editor" is just a placeholder to make the demo runnable.

Same decorator, different configuration. That's the power of decorator arguments.


The Pattern to Remember

Without arguments - Two layers:

from functools import wraps

def decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

With arguments - Three layers:

from functools import wraps

def decorator_factory(arguments):    # takes your settings
    def decorator(func):              # takes the function
        @wraps(func)
        def wrapper(*args, **kwargs):  # replaces the function
            return func(*args, **kwargs)
        return wrapper
    return decorator

The only difference is one extra outer function that holds your configuration. That's all there is to it.


Stacking Decorators

You can apply multiple decorators to a single function. They apply bottom-up: the bottom decorator wraps the function first, and the top decorator wraps the result.

@timer
@repeat(3)
def greet():
    print("Hi!")

# Equivalent to:
decorated = repeat(3)(greet)
greet = timer(decorated)

Here, repeat(3) wraps greet first, then timer wraps the result. When you call greet(), timer's wrapper runs first, which calls repeat's wrapper, which calls the original greet three times.

The bottom decorator wraps first, but the top decorator's wrapper runs first when the function is called.

When Stacking Works Well

Decorators that transform return values compose beautifully together, because each decorator receives the output of the one below it and can modify it before passing it up:

from functools import wraps

def add_exclamation(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result + "!"
    return wrapper

def make_uppercase(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper

@add_exclamation
@make_uppercase
def greet(name):
    return f"hello, {name}"

print(greet("Moussa"))  # HELLO, MOUSSA!

make_uppercase wraps first, turning the return value to "HELLO, MOUSSA".

Then add_exclamation wraps that, adding "!" to make "HELLO MOUSSA!".

Clean, predictable, composable.

💡
Because each wrapper receives a value and returns a new value, the decorators form a chain where each layer can still influence the final result.

When Stacking Gets Tricky

Decorators that use side effects (like print()) don't compose as cleanly. If one decorator prints something and another transforms return values, the printed output can't be intercepted or reordered by the other decorator. The side effects have already escaped the chain.

The general principle: When you're designing decorators that might be stacked, prefer working with return values over side effects. This gives other decorators in the chain a chance to interact with your output.

print() sends text directly to standard output and returns None, so another decorator cannot "catch" that printed text the way it can catch and modify a normal return value.


Common Decorator Patterns in the Wild

Now that you understand the mechanics, you'll start recognizing these patterns everywhere:

FastAPI routes

@app.get("/")
def root():
    return {"message": "Welcome to my API"}

Django authentication

@login_required             # simple decorator
def dashboard(request):
    return render(request, "dashboard.html")

Python builtins

class MyClass:
    @staticmethod           # simple decorator
    def utility():
        pass

    @property               # simple decorator
    def name(self):
        return self._name

Functools caching

from functools import lru_cache

@lru_cache(maxsize=128)    # decorator with arguments
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

They all follow the same principles we've learned. Some take arguments (three layers), some don't (two layers). But the core pattern is always the same: take a function in, return a wrapped version out.


What's Next?

You now have the complete picture of Python decorators, from the foundations through the common pitfalls to advanced patterns. In the final part of this series, I've put together 10 hands-on exercises that will test and solidify everything you've learned.

Subscribe to my newsletter if you don't want to miss any updates.
Thank you for reading 🙂.


This is Part 3 of my Python Decorator series. Check out Part 1: Understanding Decorators From Scratch for foundations and Part 2: The Print vs Return Trap for the most common pitfall.

Python Decorators From the Ground Up

Part 3 of 5

A 4-part series that builds your understanding of Python decorators from the ground up: no prior knowledge assumed. We start with functions as objects, work through closures and wrappers, tackle the print vs return trap that silently breaks your code, explore decorators with arguments and stacking, and finish with 10 hands-on exercises. Written by a developer who learned the hard way so you don't have to.

Up next

The Print vs Return Trap That Silently Breaks Your Python Decorators

I spent hours debugging this so you don't have to.