# Advanced Python Decorators Patterns: Arguments and Stacking

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:

```python
@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:

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

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

And on another function:

```python
@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."

```python
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:

```python
@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:

```python
# 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:

```python
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!")
```

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">Remember from the first article in this series: You will usually use <code>functools.wraps(func)</code> on the wrapper so the decorated function keeps metadata like its original name and docstring.</div>
</div>

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

```python
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:

```python
@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.

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">If <code>n</code> were <code>0</code>, this implementation would return <code>None</code>, so in real code you may want to validate that <code>n &gt;= 1</code>.</div>
</div>

* * *

## 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:

```python
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

```python
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!
```

<div data-node-type="callout">
<div data-node-type="callout-emoji">⚠</div>
<div data-node-type="callout-text">In real code, <code>current_user_role</code> would be injected (e.g, from a request context or a passed argument), not hardcoded. The hardcoded <code>"editor"</code> is just a placeholder to make the demo runnable.</div>
</div>

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

* * *

## The Pattern to Remember

**Without arguments -** Two layers:

```python
from functools import wraps

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

**With arguments -** Three layers:

```python
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.

```python
@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:

```python
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.

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">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.</div>
</div>

### 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**

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

**Django authentication**

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

**Python builtins**

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

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

**Functools caching**

```python
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*](https://blog.moussaamzat.dev/understanding-python-decorators-from-scratch) *for foundations and* [*Part 2: The* Print vs Return Trap](https://blog.moussaamzat.dev/print-vs-return-trap-python-decorators) for the most common pitfall.
