Python Decorators: Basic to Advanced

python decorators have a reputation for being confusing, but they're built entirely out of concepts already covered earlier in this series — functions as first-class objects, and closures. This python decorators tutorial builds from a first simple decorator all the way through decorator factories, stacking, and class-based decorators.

Introduction: Decorators Aren't Magic

Two prerequisite facts

Decorators rest entirely on two ideas you've already seen in this series:

  1. Functions are first-class objects — a function can be passed around, stored in a variable, and returned from another function, exactly like any other value.

  2. Closures let an inner function remember variables from its enclosing scope, even after the enclosing function has finished running (covered in full detail in the previous article).

Once both of these are familiar, a decorator is simply: a callable that takes a function and returns a new, enhanced function — without modifying the original function's actual code.

def shout(func):
    def wrapper():
        result = func()
        return result.upper()
    return wrapper

def greet():
    return "hello"

greet = shout(greet)   # manually reassigning greet to its wrapped version
print(greet())   # HELLO
The @ syntax is just sugar

The @decorator_name syntax you've likely already seen is pure syntactic sugar for exactly the manual reassignment shown above:

@shout
def greet():
    return "hello"

print(greet())   # HELLO
# Completely equivalent, without @ syntax
def greet():
    return "hello"

greet = shout(greet)

Both versions do the exact same thing. @shout placed above a function definition is just a more readable way of writing greet = shout(greet) immediately after defining greet.

Writing Your First Decorator

The standard pattern
def my_decorator(func):
    def wrapper(*args, **kwargs):
        # do something before calling func
        result = func(*args, **kwargs)
        # do something after calling func
        return result
    return wrapper

This three-part shape — an outer function taking func, an inner wrapper doing work before and/or after calling it, and the outer function returning wrapper — is the template nearly every decorator follows.

Using *args, **kwargs for universal compatibility

As covered in the earlier *args/**kwargs article, using these in the wrapper's signature is what lets a single decorator work with any function, regardless of what parameters that function actually takes:

def logger(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@logger
def add(a, b):
    return a + b

@logger
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

add(3, 5)
# Calling add with args=(3, 5), kwargs={}
# add returned 8

greet("Alex", greeting="Hi")
# Calling greet with args=('Alex',), kwargs={'greeting': 'Hi'}
# greet returned Hi, Alex!

wrapper(*args, **kwargs) simply forwards whatever it received straight through to func(*args, **kwargs), regardless of how many positional or keyword arguments were actually passed.

Critical reminder: always return the result

This is a genuinely common mistake worth flagging explicitly: if wrapper doesn't return result (or return func(*args, **kwargs) directly), the decorated function silently returns None, no matter what the original function actually computed:

def broken_decorator(func):
    def wrapper(*args, **kwargs):
        func(*args, **kwargs)   # called, but the result is never returned!
    return wrapper

@broken_decorator
def add(a, b):
    return a + b

result = add(3, 5)
print(result)   # None — the actual sum, 8, was silently discarded

This mirrors exactly the "forgetting return" mistake covered in the earlier return statements article — the exact same bug, just one layer deeper inside a decorator's wrapper function.

Preserving Metadata with functools.wraps

The hidden cost of wrapping

Here's a subtle problem with every decorator shown so far: once a function is decorated, its identity — specifically __name__, __doc__, and __annotations__ — gets replaced by the wrapper's metadata, not the original function's:

def logger(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@logger
def add(a, b):
    """Add two numbers together."""
    return a + b

print(add.__name__)   # wrapper — not "add"!
print(add.__doc__)    # None — the original docstring is gone

This isn't a cosmetic issue. It breaks help(add), makes debugging tracebacks more confusing (since errors will reference wrapper, not the actual function name), and can genuinely break framework introspection — web frameworks like Flask or FastAPI, for instance, frequently rely on a function's __name__ internally for routing, and a decorator that silently renames every route handler to "wrapper" causes real, hard-to-diagnose bugs.

The fix: functools.wraps

Applying @functools.wraps(func) to the wrapper function fixes this, copying the original function's metadata onto the wrapper automatically:

import functools

def logger(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@logger
def add(a, b):
    """Add two numbers together."""
    return a + b

print(add.__name__)   # add — correct now
print(add.__doc__)    # Add two numbers together. — correct now

This single line — @functools.wraps(func) right above your wrapper definition — is considered a non-negotiable best practice for any decorator you write. There's essentially no downside to including it, and skipping it creates exactly the kind of subtle, hard-to-trace bug described above.

Decorators with Arguments and Stacking

Building a decorator factory

What if you want a decorator that itself takes configuration arguments — something like @repeat(times=3)? This requires an extra layer of nesting: a decorator factory, which is a function that returns a decorator, which in turn returns the wrapper.

import functools

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

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

greet("Alex")
# Hello, Alex!
# Hello, Alex!
# Hello, Alex!

Three nested levels here: repeat(times) is the factory, called first with the configuration argument (times=3), returning decorator. decorator(func) is the actual decorator, receiving the function being decorated and returning wrapper. wrapper(*args, **kwargs) is what actually runs each time the decorated function is called. This structure looks like a lot at first, but it follows directly from the same closure principles covered in the previous article — each nested level captures whatever it needs from the level above it.

Stacking multiple decorators

You can apply more than one decorator to the same function, stacked vertically:

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

greet("Alex")

The order matters, and it's worth understanding precisely: decorators are applied bottom-up, meaning the one closest to the function definition wraps it first. So in the example above, @repeat(times=2) wraps greet first, and then @logger wraps the result of that. This means @repeat's repetition happens on the inside, and @logger's logging happens on the outside — @logger sees (and logs) a single call to the already-repeating function, rather than logging each individual repetition separately.

Practical patterns

You've already seen a @timer decorator in an earlier article in this series. Combining what's covered here, a @retry decorator — retrying a function a set number of times if it raises an exception — follows the same decorator-factory shape as @repeat:

import functools
import time

def retry(max_attempts=3, delay=1):
    def decorator(func):
        @functools.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:
                        raise
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, delay=1)
def unreliable_operation():
    import random
    if random.random() < 0.7:
        raise ValueError("Random failure")
    return "Success"

This same factory → decorator → wrapper shape is what underlies most configurable decorators you'll encounter in real libraries, including caching decorators, rate limiters, and permission checks in web frameworks.

Class-Based and Built-in Decorators

Class-based decorators

A decorator doesn't have to be a function — any callable object works, and a class implementing both __init__ and __call__ is a genuinely useful alternative, particularly when the decorator needs to maintain state across multiple calls:

import functools

class CallCounter:
    def __init__(self, func):
        functools.update_wrapper(self, func)   # the class-based equivalent of functools.wraps
        self.func = func
        self.call_count = 0

    def __call__(self, *args, **kwargs):
        self.call_count += 1
        print(f"{self.func.__name__} has been called {self.call_count} time(s)")
        return self.func(*args, **kwargs)

@CallCounter
def say_hello():
    print("Hello!")

say_hello()   # say_hello has been called 1 time(s) / Hello!
say_hello()   # say_hello has been called 2 time(s) / Hello!

Here, __init__ runs once, when the decorator is first applied, storing the original function. __call__ runs every time the decorated function is actually called, and — crucially — it can freely read and update self.call_count across calls, since self naturally persists as long as the decorated function object exists. This is a genuinely good fit for something like a call counter or a rate limiter, where a class's natural ability to hold onto instance attributes across calls is arguably more straightforward than threading equivalent state through a closure.

Python's built-in decorators

Three decorators are built directly into Python and used constantly in class definitions:

@staticmethod — defines a method that doesn't receive self (or cls) at all, behaving like a plain function that's simply namespaced inside the class for organizational purposes:

class MathUtils:
    @staticmethod
    def add(a, b):
        return a + b

print(MathUtils.add(3, 5))   # 8 — called without needing an instance

@classmethod — defines a method that receives the class itself (cls) as its first argument, rather than an instance. Commonly used for alternative constructors:

class Pizza:
    def __init__(self, toppings):
        self.toppings = toppings

    @classmethod
    def margherita(cls):
        return cls(["tomato", "mozzarella"])

pizza = Pizza.margherita()
print(pizza.toppings)   # ['tomato', 'mozzarella']

@property — turns a method into something accessed like a plain attribute, without parentheses, useful for computed values or adding validation logic behind what looks like simple attribute access:

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return 3.14159 * self.radius ** 2

circle = Circle(5)
print(circle.area)   # 78.53975 — accessed like an attribute, no parentheses

These three are covered in far more depth in the upcoming article on classes and object-oriented programming — for now, the key thing to recognize is that they're built on exactly the same underlying decorator mechanism covered throughout this article, just applied specifically within a class body.

A brief note on async-aware decorators

If you're working with asyncio (covered in a later article in this series), it's worth knowing that a decorator written the "normal" way — as shown throughout this article — won't correctly wrap an async def function, since calling a coroutine function doesn't execute it immediately the way calling a regular function does; it returns a coroutine object that needs to be awaited. Writing a decorator that works correctly with both regular and async functions typically means checking asyncio.iscoroutinefunction(func) inside the decorator and providing two separate wrapper implementations — one that calls func() directly, and one that awaits it. This is a genuinely more advanced pattern than anything covered in the rest of this article, but recognizing that it exists — and that a "normal" decorator silently doesn't work correctly on an async function — will save you real confusion the first time you need to decorate asynchronous code.