DEV Community

Cover image for Python Decorators - The Three-Layer Pattern
Developer Service
Developer Service

Posted on • Originally published at developer-service.blog

Python Decorators - The Three-Layer Pattern

You've probably used decorators before, @login_required, @cache, @app.route("/").

They feel like magic: one line above a function and its behavior changes entirely. But at some point you need more than an on/off switch. You need @retry(times=3), @cache(maxsize=128), @require_role("admin"). That's when most developers hit a wall.

The good news is that parameterized decorators aren't a different concept, they're just one extra function layer on top of what you already know. Once it clicks, you'll see the pattern everywhere in the Python ecosystem and start reaching for it naturally in your own code.

In this tutorial you'll go from a plain decorator to a fully parameterized one, understand exactly why the three-layer structure is necessary, and build a handful of decorators you could drop into a real project today, including a @retry decorator that handles flaky network calls and a @require_role guard for protecting API endpoints.

Let's start with a quick recap of how decorators work under the hood, because that foundation is what makes everything else make sense.


Decorators Under the Hood

If you've written a decorator before, you know the syntax.

But let's slow down and look at what Python is actually doing, because that mental model is the key to understanding parameterized decorators later.

Here's the simplest possible decorator:

def shout(fn):
    def wrapper(*args, **kwargs):
        result = fn(*args, **kwargs)
        return str(result).upper()
    return wrapper

@shout
def greet(name):
    """Greet the user"""
    return f"hello, {name}"

print(greet("alice"))  # "HELLO, ALICE"
Enter fullscreen mode Exit fullscreen mode

The @shout syntax is pure shorthand. Python translates it into exactly this:

greet = shout(greet)
Enter fullscreen mode Exit fullscreen mode

That's the whole trick.

shout receives the original function, returns a new one (wrapper), and Python rebinds the name greet to point at wrapper from that point on.

Every time you call greet(...), you're actually calling wrapper(...), which calls the original function internally.

Visually, the transformation looks like this:

Decorator Architecture

The identity problem

There's a subtle issue with the above. After decoration, greet is no longer quite itself:

print(greet.__name__)   # "wrapper"  ← wrong
print(greet.__doc__)    # None       ← lost
Enter fullscreen mode Exit fullscreen mode

Python sees wrapper where it expects greet. This breaks help(), logging, stack traces, and any tool that inspects function metadata.

The fix is functools.wraps:

import functools

def shout(fn):
    @functools.wraps(fn)       # copies __name__, __doc__, __annotations__, __wrapped__
    def wrapper(*args, **kwargs):
        result = fn(*args, **kwargs)
        return str(result).upper()
    return wrapper

@shout
def greet(name):
    """Greet the user"""
    return f"hello, {name}"

print(greet.__name__)   # "greet"
print(greet.__doc__)    # "Greet the user"
Enter fullscreen mode Exit fullscreen mode

Now greet.__name__ is "greet" and greet.__wrapped__ holds a reference to the original function, useful for testing, which you'll see later.

Think of @functools.wraps as the professional finish on every decorator you write. It costs one line and saves a lot of confusion.

With that foundation in place, it's time to add parameters and that's where things get genuinely interesting.

All the code of this and the other examples in this article are available at: https://github.com/nunombispo/python-decorators-article


The Three Layers of a Parametrized Decorator

A plain decorator takes a function and returns a function. That's one layer.

The moment you add parameters, you need somewhere to put them and that somewhere is a new outer function that runs before the decorator does.

That gives you three layers total.

Here's the structure, stripped to its bones:

def outer(param):          # layer 1: receives your arguments
    def decorator(fn):     # layer 2: receives the function
        def wrapper(*args, **kwargs):   # layer 3: runs on every call
            # param is available here
            return fn(*args, **kwargs)
        return wrapper
    return decorator
Enter fullscreen mode Exit fullscreen mode

When Python sees @outer(param), it does this in two steps:

decorator = outer(param)   # step 1: call the factory, get a decorator back
greet = decorator(greet)   # step 2: apply that decorator to the function
Enter fullscreen mode Exit fullscreen mode

Which collapses to the familiar one-liner:

greet = outer(param)(greet)
Enter fullscreen mode Exit fullscreen mode

The diagram maps this exactly:

3-layer Decorator Architecture

Why the extra layer exists

A plain @decorator works because Python calls it with the function as its only argument.

But @outer(param) is evaluated before Python passes the function, outer(param) runs immediately and must return something callable that accepts a function. That's decorator.

You're not adding complexity for its own sake; the structure is forced by the order Python evaluates things.

Closure capture

The reason param is available inside wrapper, despite outer having already returned, is closures.

When wrapper is defined inside decorator, which is defined inside outer, it captures references to variables in the enclosing scopes. param doesn't get copied into wrapper; it stays alive because wrapper holds a reference to it.

If you want a deeper understanding of how Python resolves variable names across nested scopes, check out A Practical Guide to Python Variable Scope - it covers the LEGB rule and closures in detail and will help you understand it better.

This is what makes the pattern so powerful. You can pass in times=3 or role="admin" at decoration time, and that value is quietly available every single time the wrapped function is called, no global state, no configuration objects, just a variable captured in a closure.

A concrete way to see this:

def outer(param):
    print(f"outer called with {param}")      # runs once, at decoration time
    def decorator(fn):
        print(f"decorator called with {fn}") # runs once, at decoration time
        def wrapper(*args, **kwargs):
            print(f"wrapper called, param={param}")  # runs on every call
            return fn(*args, **kwargs)
        return wrapper
    return decorator

@outer("hello")
def greet(name):
    return name
Enter fullscreen mode Exit fullscreen mode

Running this, you'll see outer and decorator print immediately when the module loads. wrapper only prints when you actually call greet(...).

That timing distinction, decoration time vs call time, is one of the most common sources of bugs when writing parameterized decorators.

With the structure clear, let's build something real with it.


Building Your First Parameterized Decorator

Time to put the three-layer pattern to work.

@repeat is the perfect first example - the logic is simple enough that the decorator structure stays front and center.

Here's the goal:

@repeat(times=3)
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!
Enter fullscreen mode Exit fullscreen mode

And here's the full implementation:

import functools

def repeat(times):                              # layer 1: factory
    def decorator(fn):                          # layer 2: decorator
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):           # layer 3: wrapper
            for _ in range(times):
                result = fn(*args, **kwargs)
            return result
        return wrapper
    return decorator
Enter fullscreen mode Exit fullscreen mode

Let's walk through each layer.

Layer 1 - repeat(times)

This is the factory. It runs exactly once, at decoration time, when Python evaluates @repeat(times=3). Its only job is to receive the argument and return a decorator. After this call, times is captured in the closure and available to everything nested inside.

Layer 2 - decorator(fn)

This is what Python passes the function to. It also runs once, at decoration time, immediately after repeat(times) returns. It receives greet as fn, applies @functools.wraps(fn) to preserve its identity, and returns wrapper as the replacement.

Layer 3 - wrapper(*args, **kwargs)

This is the function your code actually calls every time you write greet("Alice"). It has access to both times (from layer 1's closure) and fn (from layer 2's closure). The loop runs, the original function is called each iteration, and the last result is returned.

A note on return value

Storing result and returning it after the loop means the caller always gets the return value of the last call. For a @repeat decorator that's fine - but in other decorators, returning inside the loop on the first successful call is often the right choice. Always think about what your wrapper should hand back to the caller.

Checking it works

Thanks to functools.wraps, the function's identity is intact:

print(greet.__name__)     # "greet"
print(greet.__wrapped__)  # <function greet at 0x...>
Enter fullscreen mode Exit fullscreen mode

And you can access the original unwrapped function via __wrapped__ - handy when testing, since you can call greet.__wrapped__("Alice") to bypass the decorator entirely.


Decorators You'll Actually Ship

@repeat is a clean teaching example, but the pattern really earns its keep when the logic inside wrapper does something meaningful.

Here are three parameterized decorators that solve real problems.

@retry - handling flaky operations

Network calls fail. APIs rate-limit. Database connections drop.

A @retry decorator lets you express resilience in one line at the call site rather than cluttering every function with try/except loops.

import time
import functools

def retry(times=3, delay=1.0, exceptions=(Exception,)):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            last_exc = None
            for attempt in range(1, times + 1):
                print(f"Attempt {attempt} of {times}")
                try:
                    return fn(*args, **kwargs)
                except exceptions as e:
                    last_exc = e
                    if attempt < times:
                        time.sleep(delay)
            raise last_exc
        return wrapper
    return decorator

@retry(times=3, delay=0.5, exceptions=(ConnectionError, TimeoutError))
def fetch_data(url):
    ...  # flaky network call
Enter fullscreen mode Exit fullscreen mode

A few design decisions worth noting.

The exceptions parameter lets the caller decide what counts as a retry, you never want to silently swallow a ValueError or KeyError that signals a bug in your own code.

Storing last_exc and re-raising it after the loop preserves the original trace-back rather than replacing it with a generic message.

And returning immediately on success means you don't pay any extra overhead on the happy path.

@timer - measuring execution time

Useful during development and in production monitoring.

The unit parameter keeps the output readable whether you're measuring a 2-ms database query or a 45-second batch job.

import time
import functools

def timer(unit="ms"):
    units = {"ms": 1_000, "s": 1, "us": 1_000_000}

    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            start = time.perf_counter()
            result = fn(*args, **kwargs)
            elapsed = (time.perf_counter() - start) * units[unit]
            print(f"{fn.__name__} took {elapsed:.2f}{unit}")
            return result
        return wrapper
    return decorator

@timer(unit="ms")
def query_database(sql):
    ...

@timer(unit="s")
def generate_report():
    ...
Enter fullscreen mode Exit fullscreen mode

time.perf_counter() is the right choice here - it's the highest-resolution clock available and unaffected by system clock adjustments.

Notice that result is captured before printing, so the timing includes only the function itself, not the print call.

@require_role - protecting endpoints

A common pattern in Django, Flask, and FastAPI: restrict a view or route handler to users with a specific role, without repeating the same guard logic in every function body.

import functools
from functools import wraps

class User:
    def __init__(self, name, role):
        self.name = name
        self.role = role

def require_role(role):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            # in a real app, current_user comes from your auth context
            user = kwargs.get("user") or (args[0] if args else None)
            if user is None or user.role != role:
                raise PermissionError(f"Role '{role}' required.")
            return fn(*args, **kwargs)
        return wrapper
    return decorator

@require_role("admin")
def delete_user(user, target_id):
    ...

@require_role("editor")
def publish_post(user, post_id):
    ...
Enter fullscreen mode Exit fullscreen mode

The role string is captured in the closure at decoration time, so delete_user and publish_post each carry their own independent requirement - no shared state, no configuration lookup at runtime.

In a real application you'd pull the current user from a request context or session rather than a function argument, but the decorator structure stays identical.


Stacking Decorators

Nothing stops you from applying multiple decorators to the same function.

In fact, combining small focused decorators is one of the cleanest ways to compose behavior in Python:

@retry(times=3, delay=0.5)
@timer(unit="ms")
def fetch_data(url):
    ...
Enter fullscreen mode Exit fullscreen mode

But the order matters, and it trips people up.

Application order is bottom-up

Python applies decorators from the one closest to def outward. The equivalent explicit form makes this concrete:

fetch_data = retry(times=3, delay=0.5)(timer(unit="ms")(fetch_data))
Enter fullscreen mode Exit fullscreen mode

timer wraps fetch_data first, then retry wraps the result of that. So at runtime, calling fetch_data(url) enters retry's wrapper first, which calls timer's wrapper, which calls the original function.

A useful mental model: read the decorators from top to bottom to understand the call order at runtime - the topmost decorator is entered first.

@retry ← entered first at runtime, sees everything below 
@timer ← entered second def fetch_data
Enter fullscreen mode Exit fullscreen mode

In this arrangement, @timer measures the total time including all retry attempts:

@timer(unit="ms")     entered first, measures total time including all retries
@retry(times=3)       retries happen inside the timed block
def fetch_data(url):
    ...
Enter fullscreen mode Exit fullscreen mode

Swap the order and it measures only a single attempt:

@retry(times=3)       entered first, retries everything below
@timer(unit="ms")     measures a single attempt
def fetch_data(url):
    ...
Enter fullscreen mode Exit fullscreen mode

Neither is wrong - they measure different things. Being deliberate about order is part of using stacked decorators well.

Debugging stacked decorators

When something goes wrong in a stack, the first tool to reach for is __wrapped__. Because functools.wraps sets this attribute on every wrapper, you can peel back the layers manually:

fetch_data            # retry's wrapper
fetch_data.__wrapped__            # timer's wrapper
fetch_data.__wrapped__.__wrapped__ # original fetch_data
Enter fullscreen mode Exit fullscreen mode

If one of your decorators is missing functools.wraps, the chain breaks at that point - __wrapped__ won't exist and you can't see past it. This is the most common reason to go back and add @functools.wraps(fn) to an older decorator you didn't write yourself.

For a quicker inspection, inspect.unwrap() does the peeling for you:

import inspect

original = inspect.unwrap(fetch_data)
print(original)  # <function fetch_data at 0x...>
Enter fullscreen mode Exit fullscreen mode

It follows __wrapped__ all the way down to the original function in one call. Pair it with inspect.signature() to confirm the original signature is intact:

print(inspect.signature(fetch_data))          # (*args, **kwargs) if wraps is missing
print(inspect.signature(inspect.unwrap(fetch_data)))  # (url) ← correct
Enter fullscreen mode Exit fullscreen mode

A mangled signature is usually the first sign that a decorator in the stack is missing functools.wraps.

Once your decorator is solid, it's worth automating those checks - GitHub Actions for Python Projects walks you through running tests, linting, and type checks on every push with a single YAML file.


Conclusion

Parametrized decorators look intimidating at first glance, but the pattern is simpler than it appears: a factory captures your arguments, returns a decorator, which wraps your function in a closure that keeps everything in scope.

That three-layer structure is all there is to it - and once it clicks, you'll spot it everywhere. Django's @permission_required, FastAPI's @app.get("/"), Tenacity's @retry - they're all the same pattern dressed for different jobs.

Three things worth carrying forward.

First, always apply @functools.wraps - it costs one line and keeps __name__, __doc__, and __wrapped__ intact, which matters the moment you start debugging or writing tests.

Second, be deliberate about stacking order; the topmost decorator is entered first at runtime, so it sees everything below it.

Third, keep the decoration-time vs call-time distinction in mind - the factory and decorator layers run once when the module loads, wrapper runs on every call.

Mixing those two up is the most common source of subtle bugs when writing parameterized decorators.

If you find yourself copying @retry or @timer into every new project, that's a sign they've earned their own package - How to Build and Publish a Python Package to PyPI walks you through the whole process with a real project from start to finish.


Follow me on Twitter: https://twitter.com/DevAsService

Follow me on Instagram: https://www.instagram.com/devasservice/

Follow me on TikTok: https://www.tiktok.com/@devasservice

Follow me on YouTube: https://www.youtube.com/@DevAsService

Top comments (0)